From 97c22dbc050e1ceddedac5db99fb07d9c2385aaf Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 24 Nov 2023 19:13:09 -0300 Subject: [PATCH 01/11] fix: update hostname example --- lib/l10n/app_en.arb | 1 + lib/l10n/app_fr.arb | 1 + lib/l10n/app_pl.arb | 1 + lib/l10n/app_pt.arb | 1 + lib/widgets/servers/add_server.dart | 2 +- lib/widgets/servers/edit_server.dart | 2 +- macos/Flutter/GeneratedPluginRegistrant.swift | 42 +++++++++++++++++++ .../ephemeral/Flutter-Generated.xcconfig | 11 +++++ .../ephemeral/flutter_export_environment.sh | 12 ++++++ 9 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 macos/Flutter/GeneratedPluginRegistrant.swift create mode 100644 macos/Flutter/ephemeral/Flutter-Generated.xcconfig create mode 100644 macos/Flutter/ephemeral/flutter_export_environment.sh diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ea4edadc..4965e83e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -5,6 +5,7 @@ "configure": "Configure a DVR Server", "configureDescription": "Setup a connection to your remote DVR server", "hostname": "Hostname", + "hostnameExample": "demo.bluecherry.app", "port": "Port", "rtspPort": "RTSP Port", "serverName": "Server Name", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 8eb44bac..98ec57af 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -5,6 +5,7 @@ "configure": "Configurer un serveur DVR", "configureDescription": "Configurons une connexion à votre serveur DVR distant", "hostname": "Nom d'hôte", + "hostnameExample": "demo.bluecherry.app", "port": "Port", "rtspPort": "RTSP Port", "serverName": "Server Name", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index f3de1b3a..2de0c591 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -5,6 +5,7 @@ "configure": "Konfiguracja serwera DVR", "configureDescription": "Ustawienia połączenia ze zdalnym serwerem DVR", "hostname": "Nazwa hosta", + "hostnameExample": "demo.bluecherry.app", "port": "Port", "rtspPort": "RTSP Port", "serverName": "Server Name", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index c00c4ca2..698697bc 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -5,6 +5,7 @@ "configure": "Configure um Servidor DVR", "configureDescription": "Configure uma conexão com seu servidor DVR remoto", "hostname": "Hostname", + "hostnameExample": "demo.bluecherry.app", "port": "Porta", "rtspPort": "Porta RTSP", "serverName": "Nome do servidor", diff --git a/lib/widgets/servers/add_server.dart b/lib/widgets/servers/add_server.dart index 24dbad89..6dc40ea3 100644 --- a/lib/widgets/servers/add_server.dart +++ b/lib/widgets/servers/add_server.dart @@ -313,7 +313,7 @@ class _ConfigureDVRServerScreenState extends State { style: theme.textTheme.headlineMedium, decoration: InputDecoration( label: Text(loc.hostname), - hintText: loc.serverHostnameExample, + hintText: loc.hostnameExample, border: const OutlineInputBorder(), ), ); diff --git a/lib/widgets/servers/edit_server.dart b/lib/widgets/servers/edit_server.dart index 2fbed391..b46b5598 100644 --- a/lib/widgets/servers/edit_server.dart +++ b/lib/widgets/servers/edit_server.dart @@ -124,7 +124,7 @@ class _EditServerState extends State { style: theme.textTheme.headlineMedium, decoration: InputDecoration( label: Text(loc.hostname), - hintText: loc.serverHostnameExample, + hintText: loc.hostnameExample, border: const OutlineInputBorder(), ), ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 00000000..fa1aec86 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,42 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import app_links +import awesome_notifications +import connectivity_plus +import device_info_plus +import firebase_core +import firebase_messaging +import media_kit_libs_macos_video +import media_kit_video +import package_info_plus +import path_provider_foundation +import screen_brightness_macos +import screen_retriever +import system_date_time_format +import url_launcher_macos +import wakelock_plus +import window_manager + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + AwesomeNotificationsPlugin.register(with: registry.registrar(forPlugin: "AwesomeNotificationsPlugin")) + ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) + MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin")) + ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) + SystemDateTimeFormatPlugin.register(with: registry.registrar(forPlugin: "SystemDateTimeFormatPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) +} diff --git a/macos/Flutter/ephemeral/Flutter-Generated.xcconfig b/macos/Flutter/ephemeral/Flutter-Generated.xcconfig new file mode 100644 index 00000000..fd218d8d --- /dev/null +++ b/macos/Flutter/ephemeral/Flutter-Generated.xcconfig @@ -0,0 +1,11 @@ +// This is a generated file; do not edit or check into version control. +FLUTTER_ROOT=C:\Users\bruno\Documents\flutter\flutter +FLUTTER_APPLICATION_PATH=C:\Users\bruno\Documents\flutter\projects\unity +COCOAPODS_PARALLEL_CODE_SIGN=true +FLUTTER_BUILD_DIR=build +FLUTTER_BUILD_NAME=3.0.013 +FLUTTER_BUILD_NUMBER=3.0.013 +DART_OBFUSCATION=false +TRACK_WIDGET_CREATION=true +TREE_SHAKE_ICONS=false +PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/macos/Flutter/ephemeral/flutter_export_environment.sh b/macos/Flutter/ephemeral/flutter_export_environment.sh new file mode 100644 index 00000000..da939398 --- /dev/null +++ b/macos/Flutter/ephemeral/flutter_export_environment.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=C:\Users\bruno\Documents\flutter\flutter" +export "FLUTTER_APPLICATION_PATH=C:\Users\bruno\Documents\flutter\projects\unity" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=3.0.013" +export "FLUTTER_BUILD_NUMBER=3.0.013" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json" From 59b5d9bffe1b66838da11c2deb3923e590b6f171 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 24 Nov 2023 19:16:29 -0300 Subject: [PATCH 02/11] feat: click to move timeline --- .../events_timeline/desktop/timeline.dart | 86 ++++++++++--------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/lib/widgets/events_timeline/desktop/timeline.dart b/lib/widgets/events_timeline/desktop/timeline.dart index c9146af6..88c02ff4 100644 --- a/lib/widgets/events_timeline/desktop/timeline.dart +++ b/lib/widgets/events_timeline/desktop/timeline.dart @@ -684,46 +684,19 @@ class _TimelineEventsViewState extends State { Expanded( child: GestureDetector( behavior: HitTestBehavior.opaque, + onTapUp: (details) { + _onMove( + details.localPosition, + constraints, + tileWidth, + ); + }, onHorizontalDragUpdate: (details) { - if (!timeline.zoomController.hasClients || - details.localPosition.dx >= - (constraints.maxWidth - - _kDeviceNameWidth)) { - return; - } - final pointerPosition = (details - .localPosition.dx + - timeline.zoomController.offset) / - tileWidth; - if (pointerPosition < 0 || - pointerPosition > 1) { - return; - } - - final seconds = - (_secondsInADay * pointerPosition) - .round(); - final position = Duration(seconds: seconds); - timeline.seekTo(position); - - if (timeline.zoom > 1.0) { - // the position that the seeker will start moving - // 100. removes it from the border - final endPosition = constraints.maxWidth - - _kDeviceNameWidth - - 100.0; - if (details.localPosition.dx >= - endPosition) { - timeline.scrollTo( - timeline.zoomController.offset + 25.0, - ); - } else if (details.localPosition.dx <= - 100.0) { - timeline.scrollTo( - timeline.zoomController.offset - 25.0, - ); - } - } + _onMove( + details.localPosition, + constraints, + tileWidth, + ); }, child: Builder(builder: (context) { return ScrollConfiguration( @@ -832,6 +805,41 @@ class _TimelineEventsViewState extends State { timeline.zoom += 0.6; } } + + void _onMove( + Offset localPosition, + BoxConstraints constraints, + double tileWidth, + ) { + if (!timeline.zoomController.hasClients || + localPosition.dx >= (constraints.maxWidth - _kDeviceNameWidth)) { + return; + } + final pointerPosition = + (localPosition.dx + timeline.zoomController.offset) / tileWidth; + if (pointerPosition < 0 || pointerPosition > 1) { + return; + } + + final seconds = (_secondsInADay * pointerPosition).round(); + final position = Duration(seconds: seconds); + timeline.seekTo(position); + + if (timeline.zoom > 1.0) { + // the position that the seeker will start moving + // 100. removes it from the border + final endPosition = constraints.maxWidth - _kDeviceNameWidth - 100.0; + if (localPosition.dx >= endPosition) { + timeline.scrollTo( + timeline.zoomController.offset + 25.0, + ); + } else if (localPosition.dx <= 100.0) { + timeline.scrollTo( + timeline.zoomController.offset - 25.0, + ); + } + } + } } class _TimelineTile extends StatelessWidget { From 2003516e3f83710d2a4a2552bea2b62ea75bea74 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 24 Nov 2023 19:23:26 -0300 Subject: [PATCH 03/11] fix: do not require the rtsp port when adding a new server --- lib/widgets/servers/add_server.dart | 18 ++++++++++-------- lib/widgets/servers/edit_server.dart | 13 +++++++------ 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/lib/widgets/servers/add_server.dart b/lib/widgets/servers/add_server.dart index 6dc40ea3..a09eca03 100644 --- a/lib/widgets/servers/add_server.dart +++ b/lib/widgets/servers/add_server.dart @@ -340,12 +340,13 @@ class _ConfigureDVRServerScreenState extends State { final rtspPortField = TextFormField( enabled: !disableFinishButton, - validator: (value) { - if (value == null || value.isEmpty) { - return loc.errorTextField(loc.rtspPort); - } - return null; - }, + // https://github.com/bluecherrydvr/unity/issues/182 + // validator: (value) { + // if (value == null || value.isEmpty) { + // return loc.errorTextField(loc.rtspPort); + // } + // return null; + // }, controller: rtspPortController, autofocus: true, keyboardType: TextInputType.number, @@ -633,15 +634,16 @@ class _ConfigureDVRServerScreenState extends State { } if (mounted) setState(() => disableFinishButton = true); + final port = int.parse(portController.text.trim()); final server = await API.instance.checkServerCredentials( Server( name, hostname, - int.parse(portController.text.trim()), + port, usernameController.text.trim(), passwordController.text, [], - rtspPort: int.parse(rtspPortController.text.trim()), + rtspPort: int.tryParse(rtspPortController.text.trim()) ?? port, savePassword: savePassword, connectAutomaticallyAtStartup: connectAutomaticallyAtStartup, ), diff --git a/lib/widgets/servers/edit_server.dart b/lib/widgets/servers/edit_server.dart index b46b5598..36f2eb04 100644 --- a/lib/widgets/servers/edit_server.dart +++ b/lib/widgets/servers/edit_server.dart @@ -157,12 +157,13 @@ class _EditServerState extends State { flex: 2, child: TextFormField( enabled: !disableFinishButton, - validator: (value) { - if (value == null || value.isEmpty) { - return loc.errorTextField(loc.rtspPort); - } - return null; - }, + // https://github.com/bluecherrydvr/unity/issues/182 + // validator: (value) { + // if (value == null || value.isEmpty) { + // return loc.errorTextField(loc.rtspPort); + // } + // return null; + // }, controller: rtspPortController, autofocus: true, keyboardType: TextInputType.number, From f0cb80e1103066387006787d5ba95afe61b62ac8 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 24 Nov 2023 20:07:10 -0300 Subject: [PATCH 04/11] feat: fallback source It defaults to the opposite. If it is RTSP, it fallbacks to HLS. If it is HLS, fallback to RTSP. If it is MJPEG, fallbacks to HLS. Logs all the video errors to file. --- lib/models/device.dart | 4 ++-- lib/utils/video_player.dart | 22 ++++++++++++++----- .../lib/unity_video_player_main.dart | 10 ++++++++- ...unity_video_player_platform_interface.dart | 12 +++++----- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/lib/models/device.dart b/lib/models/device.dart index 520a3cdc..1d17d30e 100644 --- a/lib/models/device.dart +++ b/lib/models/device.dart @@ -243,7 +243,7 @@ class Device { ).toString()); } - Future getHLSUrl([Device? device]) async { + Future getHLSUrl([Device? device]) async { // return hlsURL; device ??= this; var data = { @@ -276,7 +276,7 @@ class Device { debugPrint('Request failed with status: ${response.statusCode}'); } - return null; + return hlsURL; } /// Returns the full name of this device, including the server name. diff --git a/lib/utils/video_player.dart b/lib/utils/video_player.dart index fd0fae64..2b3ab6d3 100644 --- a/lib/utils/video_player.dart +++ b/lib/utils/video_player.dart @@ -19,6 +19,7 @@ import 'package:bluecherry_client/models/device.dart'; import 'package:bluecherry_client/providers/settings_provider.dart'; +import 'package:bluecherry_client/utils/logging.dart'; import 'package:flutter/widgets.dart'; import 'package:unity_video_player/unity_video_player.dart'; @@ -54,18 +55,29 @@ class UnityPlayers with ChangeNotifier { ..setSpeed(1.0); Future setSource() async { - final source = + final (String source, Future fallback) = switch (device.preferredStreamingType ?? settings.streamingType) { - StreamingType.rtsp => device.rtspURL, - StreamingType.hls => (await device.getHLSUrl()) ?? device.hlsURL, - StreamingType.mjpeg => device.mjpegURL, + StreamingType.rtsp => (device.rtspURL, device.getHLSUrl()), + StreamingType.hls => ( + await device.getHLSUrl(), + Future.value(device.rtspURL) + ), + StreamingType.mjpeg => (device.mjpegURL, Future.value(device.hlsURL)), }; debugPrint(source); - controller.setDataSource(source); + controller + ..fallbackUrl = fallback + ..setDataSource(source); } setSource(); + controller.onError.listen((event) { + writeLogToFile( + 'An error ocurred when playing a video (${controller.dataSource}): $event\n', + ); + }); + return controller; } diff --git a/packages/unity_video_player/unity_video_player_main/lib/unity_video_player_main.dart b/packages/unity_video_player/unity_video_player_main/lib/unity_video_player_main.dart index f4153ad9..ff2d3361 100644 --- a/packages/unity_video_player/unity_video_player_main/lib/unity_video_player_main.dart +++ b/packages/unity_video_player/unity_video_player_main/lib/unity_video_player_main.dart @@ -202,9 +202,16 @@ class UnityVideoPlayerMediaKit extends UnityVideoPlayer { } } - errorStream = mkPlayer.stream.error.listen((event) { + errorStream = mkPlayer.stream.error.listen((event) async { debugPrint('==== VIDEO ERROR HAPPENED with $dataSource'); debugPrint('==== $event'); + + // If the video is not supported, try to play the fallback url + if (event == 'Failed to recognize file format.' && + fallbackUrl != null && + lastImageUpdate != null) { + setDataSource(await fallbackUrl!); + } }); } @@ -263,6 +270,7 @@ class UnityVideoPlayerMediaKit extends UnityVideoPlayer { @override Future setDataSource(String url, {bool autoPlay = true}) { if (url == dataSource) return Future.value(); + debugPrint('Playing $url'); return ensureVideoControllerInitialized((controller) async { await mkPlayer.setPlaylistMode(PlaylistMode.loop); // do not use mkPlayer.add because it doesn't support auto play diff --git a/packages/unity_video_player/unity_video_player_platform_interface/lib/unity_video_player_platform_interface.dart b/packages/unity_video_player/unity_video_player_platform_interface/lib/unity_video_player_platform_interface.dart index 8d90227f..d551cbfe 100644 --- a/packages/unity_video_player/unity_video_player_platform_interface/lib/unity_video_player_platform_interface.dart +++ b/packages/unity_video_player/unity_video_player_platform_interface/lib/unity_video_player_platform_interface.dart @@ -335,17 +335,22 @@ enum UnityVideoQuality { } abstract class UnityVideoPlayer { + Future? fallbackUrl; + static UnityVideoPlayer create({ UnityVideoQuality quality = UnityVideoQuality.p360, bool enableCache = false, RTSPProtocol? rtspProtocol, + Future? fallbackUrl, }) { return UnityVideoPlayerInterface.instance.createPlayer( width: quality.resolution.width.toInt(), height: quality.resolution.height.toInt(), enableCache: enableCache, rtspProtocol: rtspProtocol, - )..quality = quality; + ) + ..quality = quality + ..fallbackUrl = fallbackUrl; } static const timerInterval = Duration(seconds: 6); @@ -361,10 +366,7 @@ abstract class UnityVideoPlayer { UnityVideoQuality? quality; - UnityVideoPlayer({ - this.width, - this.height, - }) { + UnityVideoPlayer({this.width, this.height}) { _onDurationUpdateSubscription = onDurationUpdate.listen(_onDurationUpdate); } From 7ea0a49076474b71eaa091111a6d408f49c0dc18 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 24 Nov 2023 21:29:16 -0300 Subject: [PATCH 05/11] fix: multicast viewport on full screen --- lib/widgets/player/live_player.dart | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/widgets/player/live_player.dart b/lib/widgets/player/live_player.dart index 73dfdc5f..f75e2dce 100644 --- a/lib/widgets/player/live_player.dart +++ b/lib/widgets/player/live_player.dart @@ -25,6 +25,7 @@ import 'package:bluecherry_client/providers/settings_provider.dart'; import 'package:bluecherry_client/utils/methods.dart'; import 'package:bluecherry_client/utils/window.dart'; import 'package:bluecherry_client/widgets/desktop_buttons.dart'; +import 'package:bluecherry_client/widgets/device_grid/desktop/multicast_view.dart'; import 'package:bluecherry_client/widgets/device_grid/video_status_label.dart'; import 'package:bluecherry_client/widgets/error_warning.dart'; import 'package:bluecherry_client/widgets/misc.dart'; @@ -330,6 +331,16 @@ class __DesktopLivePlayerState extends State<_DesktopLivePlayer> { paneBuilder: (context, player) { return Stack(children: [ if (commands.isNotEmpty) PTZData(commands: commands), + Positioned.fill( + child: Center( + child: AspectRatio( + aspectRatio: player.aspectRatio, + child: MulticastViewport( + device: widget.device, + ), + ), + ), + ), PositionedDirectional( bottom: 8.0, end: 8.0, From 82da11a069b104c1d6e3048298ef161d88a89ee8 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Fri, 24 Nov 2023 21:52:02 -0300 Subject: [PATCH 06/11] feat: double click to switch between matrix types and do not show multicast view if the player hasn't loaded yet --- lib/providers/desktop_view_provider.dart | 23 +++++++++++++++++++ .../desktop/desktop_device_grid.dart | 17 ++++++-------- .../device_grid/desktop/external_stream.dart | 10 ++++++++ .../device_grid/desktop/multicast_view.dart | 15 ++++++++++-- 4 files changed, 53 insertions(+), 12 deletions(-) diff --git a/lib/providers/desktop_view_provider.dart b/lib/providers/desktop_view_provider.dart index c46827e6..b5503414 100644 --- a/lib/providers/desktop_view_provider.dart +++ b/lib/providers/desktop_view_provider.dart @@ -266,4 +266,27 @@ class DesktopViewProvider extends ChangeNotifier { notifyListeners(); return _save(notifyListeners: false); } + + /// Updates a device in all the layouts. + /// + /// If [reload] is `true`, the device player will be reloaded. + Future updateDevice( + Device previousDevice, + Device device, { + bool reload = false, + }) { + for (final layout in layouts) { + final index = layout.devices.indexOf(previousDevice); + if (!index.isNegative) { + layout.devices[index] = device; + } + } + + if (reload) { + UnityPlayers.reloadDevice(device); + } + + notifyListeners(); + return _save(notifyListeners: false); + } } diff --git a/lib/widgets/device_grid/desktop/desktop_device_grid.dart b/lib/widgets/device_grid/desktop/desktop_device_grid.dart index c9414c47..8e25e382 100644 --- a/lib/widgets/device_grid/desktop/desktop_device_grid.dart +++ b/lib/widgets/device_grid/desktop/desktop_device_grid.dart @@ -636,16 +636,13 @@ class _DesktopTileViewportState extends State { onFitChanged: widget.onFitChanged, ); if (device != null && mounted) { - view.layouts[view.currentLayoutIndex].devices[view - .currentLayout.devices - .indexOf(widget.device)] = device; - if (device.url != widget.device.url || - device.preferredStreamingType != - widget.device.preferredStreamingType) { - UnityPlayers.reloadDevice(device); - } - // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member - view.notifyListeners(); + view.updateDevice( + widget.device, + device, + reload: device.url != widget.device.url || + device.preferredStreamingType != + widget.device.preferredStreamingType, + ); updateVolume(); } }, diff --git a/lib/widgets/device_grid/desktop/external_stream.dart b/lib/widgets/device_grid/desktop/external_stream.dart index 7f8a31bf..fdbf5a63 100644 --- a/lib/widgets/device_grid/desktop/external_stream.dart +++ b/lib/widgets/device_grid/desktop/external_stream.dart @@ -58,6 +58,16 @@ enum MatrixType { MatrixType.t1 => const Icon(Icons.square_outlined), }; } + + MatrixType get next { + return switch (this) { + MatrixType.t16 => MatrixType.t9, + MatrixType.t9 => MatrixType.t4, + MatrixType.t4 => MatrixType.t16, + // ideally, t1 is never reached + MatrixType.t1 => MatrixType.t16, + }; + } } class AddExternalStreamDialog extends StatefulWidget { diff --git a/lib/widgets/device_grid/desktop/multicast_view.dart b/lib/widgets/device_grid/desktop/multicast_view.dart index 9ee0af5c..8bc18e99 100644 --- a/lib/widgets/device_grid/desktop/multicast_view.dart +++ b/lib/widgets/device_grid/desktop/multicast_view.dart @@ -21,6 +21,7 @@ import 'dart:async'; import 'dart:math'; import 'package:bluecherry_client/models/device.dart'; +import 'package:bluecherry_client/providers/desktop_view_provider.dart'; import 'package:bluecherry_client/providers/settings_provider.dart'; import 'package:bluecherry_client/utils/constants.dart'; import 'package:bluecherry_client/widgets/misc.dart'; @@ -92,8 +93,11 @@ class _MulticastViewportState extends State { final settings = context.watch(); final view = UnityVideoView.maybeOf(context); final theme = Theme.of(context); + final views = context.watch(); - if (view == null || !settings.betaMatrixedZoomEnabled) { + if (view == null || + view.lastImageUpdate == null || + !settings.betaMatrixedZoomEnabled) { return const SizedBox.shrink(); } @@ -113,7 +117,6 @@ class _MulticastViewportState extends State { }); (int row, int column) next; - if (scaleChange == 1.0) { next = nextZoom(currentZoom!, size); } else { @@ -150,6 +153,14 @@ class _MulticastViewportState extends State { return GestureDetector( behavior: HitTestBehavior.opaque, + onDoubleTap: () { + views.updateDevice( + widget.device, + widget.device.copyWith( + matrixType: widget.device.matrixType.next, + ), + ); + }, onTap: () { view.player.crop(row, col, size); currentZoom = (row, col); From 3830a68ccdd24796b0f8786ad61f504231c03b44 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 25 Nov 2023 09:25:31 -0300 Subject: [PATCH 07/11] chore: update models --- lib/api/events.dart | 21 ++-- ...firebase_messaging_background_handler.dart | 9 +- lib/models/device.dart | 47 ++++---- lib/models/event.dart | 100 ++++++++-------- lib/models/layout.dart | 23 ++-- lib/models/server.dart | 107 ++++++++++++------ lib/widgets/servers/add_server.dart | 14 +-- 7 files changed, 175 insertions(+), 146 deletions(-) diff --git a/lib/api/events.dart b/lib/api/events.dart index e0a84f7b..7783d67e 100644 --- a/lib/api/events.dart +++ b/lib/api/events.dart @@ -95,7 +95,7 @@ extension EventsExtension on API { .cast() .map((eventObject) { final published = DateTime.parse(eventObject['published']).toLocal(); - final event = Event.factory( + final event = Event( server: server, id: () { final idObject = eventObject['id'].toString(); @@ -143,21 +143,22 @@ extension EventsExtension on API { .map((e) { if (!e.containsKey('content')) debugPrint(e.toString()); return Event( - server, - int.parse(e['id']['raw']), - int.parse((e['category']['term'] as String).split('/').first), - e['title']['\$t'], - e['published'] == null || e['published']['\$t'] == null + server: server, + id: int.parse(e['id']['raw']), + deviceID: + int.parse((e['category']['term'] as String).split('/').first), + title: e['title']['\$t'], + published: e['published'] == null || e['published']['\$t'] == null ? DateTime.now().toLocal() : DateTime.parse(e['published']['\$t']).toLocal(), - e['updated'] == null || e['updated']['\$t'] == null + updated: e['updated'] == null || e['updated']['\$t'] == null ? DateTime.now().toLocal() : DateTime.parse(e['updated']['\$t']).toLocal(), - e['category']['term'], - !e.containsKey('content') + category: e['category']['term'], + mediaID: !e.containsKey('content') ? null : int.parse(e['content']['media_id']), - !e.containsKey('content') + mediaURL: !e.containsKey('content') ? null : Uri.parse( e['content'][r'$t'].replaceAll( diff --git a/lib/firebase_messaging_background_handler.dart b/lib/firebase_messaging_background_handler.dart index 22f6d4b7..e9217d17 100644 --- a/lib/firebase_messaging_background_handler.dart +++ b/lib/firebase_messaging_background_handler.dart @@ -205,12 +205,9 @@ Future _backgroundClickAction(ReceivedAction action) async { final server = ServersProvider.instance.servers .firstWhere((server) => server.serverUUID == serverUUID); final device = Device( - name!, - int.tryParse(id ?? '0') ?? 0, - true, - 0, - 0, - server, + name: name!, + id: int.tryParse(id ?? '0') ?? 0, + server: server, ); final player = UnityPlayers.forDevice(device); // No [DeviceFullscreenViewer] route is ever pushed due to notification click into the navigator. diff --git a/lib/models/device.dart b/lib/models/device.dart index 1d17d30e..fa6a7c25 100644 --- a/lib/models/device.dart +++ b/lib/models/device.dart @@ -115,13 +115,13 @@ class Device { final ExternalDeviceData? externalData; /// Creates a device. - Device( - this.name, - this.id, - this.status, + Device({ + required this.name, + required this.id, + this.status = true, this.resolutionX, this.resolutionY, - this.server, { + required this.server, this.hasPTZ = false, this.url, this.matrixType = MatrixType.t16, @@ -130,6 +130,7 @@ class Device { this.externalData, }); + /// Creates a device with fake values. Device.dump({ this.name = 'device', this.id = -1, @@ -170,12 +171,12 @@ class Device { factory Device.fromServerJson(Map map, Server server) { return Device( - map['device_name'], - int.tryParse(map['id']) ?? 0, - map['status'] == 'OK', - int.tryParse(map['resolutionX']), - int.tryParse(map['resolutionY']), - server, + name: map['device_name'], + id: int.tryParse(map['id']) ?? 0, + status: map['status'] == 'OK', + resolutionX: int.tryParse(map['resolutionX']), + resolutionY: int.tryParse(map['resolutionY']), + server: server, hasPTZ: map['ptz_control_protocol'] != null, ); } @@ -340,12 +341,12 @@ class Device { ExternalDeviceData? externalData, }) => Device( - name ?? this.name, - id ?? this.id, - status ?? this.status, - resolutionX ?? this.resolutionX, - resolutionY ?? this.resolutionY, - server ?? this.server, + name: name ?? this.name, + id: id ?? this.id, + status: status ?? this.status, + resolutionX: resolutionX ?? this.resolutionX, + resolutionY: resolutionY ?? this.resolutionY, + server: server ?? this.server, hasPTZ: hasPTZ ?? this.hasPTZ, url: url ?? this.url, matrixType: matrixType ?? this.matrixType, @@ -374,15 +375,15 @@ class Device { factory Device.fromJson(Map json) { return Device( - json['name'], - int.tryParse(json['id']?.toString() ?? + name: json['name'], + id: int.tryParse(json['id']?.toString() ?? json['uri']?.toString().replaceAll('live/', '') ?? '') ?? 0, - json['status'], - json['resolutionX'], - json['resolutionY'], - Server.fromJson(json['server'] as Map), + status: json['status'] ?? false, + resolutionX: json['resolutionX'], + resolutionY: json['resolutionY'], + server: Server.fromJson(json['server'] as Map), hasPTZ: json['hasPTZ'] ?? false, url: json['url'], matrixType: MatrixType.values[json['matrixType'] ?? 0], diff --git a/lib/models/event.dart b/lib/models/event.dart index 8c90a18f..8287e88c 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -23,7 +23,7 @@ import 'package:bluecherry_client/utils/extensions.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -/// An [Event] received from the [Server] logs. +/// An [Event] received from the [Server]. class Event { final Server server; final int id; @@ -35,19 +35,7 @@ class Event { final int? mediaID; final Uri? mediaURL; - const Event( - this.server, - this.id, - this.deviceID, - this.title, - this.published, - this.updated, - this.category, - this.mediaID, - this.mediaURL, - ); - - const Event.factory({ + const Event({ required this.server, required this.id, required this.deviceID, @@ -90,28 +78,33 @@ class Event { } @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Event && - id == other.id && - deviceID == other.deviceID && - title == other.title && - published == other.published && - updated == other.updated && - category == other.category && - mediaID == other.mediaID && - mediaURL == other.mediaURL; + other.server == server && + other.id == id && + other.deviceID == deviceID && + other.title == title && + other.published == published && + other.updated == updated && + other.category == category && + other.mediaID == mediaID && + other.mediaURL == mediaURL; } @override - int get hashCode => - id.hashCode ^ - deviceID.hashCode ^ - title.hashCode ^ - published.hashCode ^ - updated.hashCode ^ - category.hashCode ^ - mediaID.hashCode ^ - mediaURL.hashCode; + int get hashCode { + return server.hashCode ^ + id.hashCode ^ + deviceID.hashCode ^ + title.hashCode ^ + published.hashCode ^ + updated.hashCode ^ + category.hashCode ^ + mediaID.hashCode ^ + mediaURL.hashCode; + } @override String toString() => @@ -127,18 +120,19 @@ class Event { String? category, int? mediaID, Uri? mediaURL, - ) => - Event( - server ?? this.server, - deviceID ?? this.deviceID, - id ?? this.id, - title ?? this.title, - published ?? this.published, - updated ?? this.updated, - category ?? this.category, - mediaID ?? this.mediaID, - mediaURL ?? this.mediaURL, - ); + ) { + return Event( + server: server ?? this.server, + deviceID: deviceID ?? this.deviceID, + id: id ?? this.id, + title: title ?? this.title, + published: published ?? this.published, + updated: updated ?? this.updated, + category: category ?? this.category, + mediaID: mediaID ?? this.mediaID, + mediaURL: mediaURL ?? this.mediaURL, + ); + } Map toJson() => { 'server': server.toJson(devices: false), @@ -154,15 +148,15 @@ class Event { factory Event.fromJson(Map json) { return Event( - Server.fromJson(json['server']), - json['deviceID'], - json['id'], - json['title'], - DateTime.parse(json['published']), - DateTime.parse(json['updated']), - json['category'], - json['mediaID'], - Uri.parse(json['mediaURL']), + server: Server.fromJson(json['server']), + deviceID: json['deviceID'], + id: json['id'], + title: json['title'], + published: DateTime.parse(json['published']), + updated: DateTime.parse(json['updated']), + category: json['category'], + mediaID: json['mediaID'], + mediaURL: Uri.parse(json['mediaURL']), ); } diff --git a/lib/models/layout.dart b/lib/models/layout.dart index 3547afb9..197182ca 100644 --- a/lib/models/layout.dart +++ b/lib/models/layout.dart @@ -209,18 +209,17 @@ class Layout { } yield Device( - name ?? '', - int.parse(id), - true, - 640, - 480, - Server( - server, - server, - int.parse(serverPort), - '', - '', - [], + name: name ?? '', + id: int.parse(id), + resolutionX: 640, + resolutionY: 480, + server: Server( + name: server, + ip: server, + port: int.parse(serverPort), + login: '', + password: '', + devices: [], ), ); } diff --git a/lib/models/server.dart b/lib/models/server.dart index 37dd2997..ef8653df 100644 --- a/lib/models/server.dart +++ b/lib/models/server.dart @@ -19,23 +19,41 @@ import 'package:bluecherry_client/models/device.dart'; import 'package:bluecherry_client/utils/constants.dart'; +import 'package:flutter/foundation.dart'; /// A [Server] added by a user. class Server { + /// The name of the server. final String name; + + /// The IP address of the server. final String ip; + + /// The port of the server. final int port; + + /// The RTSP port of the server. + /// + /// This is used to connect to the RTSP streams. final int rtspPort; + /// The username to connect to the server. final String login; + + /// The password to connect to the server. final String password; - final bool savePassword; + + /// Whether to connect to this server automatically at startup. + /// + /// If false, the server will have to be connected to manually. final bool connectAutomaticallyAtStartup; + /// The list of devices that are available on this server. List devices = []; final String? serverUUID; final String? cookie; + /// Whether this server is online or not. bool online = true; /// Whether the server has their certificates. This enables us to make use of @@ -46,22 +64,27 @@ class Server { /// * [Device.hlsURL] bool passedCertificates = true; - Server( - this.name, - this.ip, - this.port, - this.login, - this.password, - this.devices, { + /// Creates a new [Server]. + Server({ + required this.name, + required this.ip, + required this.port, + required this.login, + required this.password, + required this.devices, this.rtspPort = kDefaultRTSPPort, this.serverUUID, this.cookie, - this.savePassword = false, this.connectAutomaticallyAtStartup = true, this.online = true, this.passedCertificates = true, }); + /// Creates a server with fake values. + /// + /// See also: + /// + /// * [Device.dump] Server.dump({ this.name = 'server', this.ip = 'server:ip', @@ -72,7 +95,6 @@ class Server { this.rtspPort = kDefaultRTSPPort, this.serverUUID, this.cookie, - this.savePassword = false, this.connectAutomaticallyAtStartup = true, this.online = true, this.passedCertificates = true, @@ -87,22 +109,39 @@ class Server { 'Server($name, $ip, $port, $rtspPort, $login, $password, $devices, $serverUUID, $cookie, $online, $passedCertificates)'; @override - bool operator ==(dynamic other) { + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Server && - ip == other.ip && - port == other.port && - login == other.login && - password == other.password && - rtspPort == other.rtspPort; + other.name == name && + other.ip == ip && + other.port == port && + other.rtspPort == rtspPort && + other.login == login && + other.password == password && + other.connectAutomaticallyAtStartup == connectAutomaticallyAtStartup && + listEquals(other.devices, devices) && + other.serverUUID == serverUUID && + other.cookie == cookie && + other.online == online && + other.passedCertificates == passedCertificates; } @override - int get hashCode => - ip.hashCode ^ - port.hashCode ^ - login.hashCode ^ - password.hashCode ^ - rtspPort.hashCode; + int get hashCode { + return name.hashCode ^ + ip.hashCode ^ + port.hashCode ^ + rtspPort.hashCode ^ + login.hashCode ^ + password.hashCode ^ + connectAutomaticallyAtStartup.hashCode ^ + devices.hashCode ^ + serverUUID.hashCode ^ + cookie.hashCode ^ + online.hashCode ^ + passedCertificates.hashCode; + } Server copyWith({ String? name, @@ -117,12 +156,12 @@ class Server { bool? online, }) { return Server( - name ?? this.name, - ip ?? this.ip, - port ?? this.port, - login ?? this.login, - password ?? this.password, - devices ?? this.devices, + name: name ?? this.name, + ip: ip ?? this.ip, + port: port ?? this.port, + login: login ?? this.login, + password: password ?? this.password, + devices: devices ?? this.devices, rtspPort: rtspPort ?? this.rtspPort, serverUUID: serverUUID ?? this.serverUUID, cookie: cookie ?? this.cookie, @@ -145,12 +184,12 @@ class Server { }; factory Server.fromJson(Map json) => Server( - json['name'], - json['ip'], - json['port'], - json['login'], - json['password'], - (json['devices'] as List) + name: json['name'], + ip: json['ip'], + port: json['port'], + login: json['login'], + password: json['password'], + devices: (json['devices'] as List) .cast>() .map(Device.fromJson) .toList() diff --git a/lib/widgets/servers/add_server.dart b/lib/widgets/servers/add_server.dart index a09eca03..32fe5dbd 100644 --- a/lib/widgets/servers/add_server.dart +++ b/lib/widgets/servers/add_server.dart @@ -267,7 +267,6 @@ class _ConfigureDVRServerScreenState extends State { final passwordController = TextEditingController(); bool showingPassword = false; - bool savePassword = true; bool nameTextFieldEverFocused = false; bool connectAutomaticallyAtStartup = true; bool disableFinishButton = false; @@ -637,14 +636,13 @@ class _ConfigureDVRServerScreenState extends State { final port = int.parse(portController.text.trim()); final server = await API.instance.checkServerCredentials( Server( - name, - hostname, - port, - usernameController.text.trim(), - passwordController.text, - [], + name: name, + ip: hostname, + port: port, + login: usernameController.text.trim(), + password: passwordController.text, + devices: [], rtspPort: int.tryParse(rtspPortController.text.trim()) ?? port, - savePassword: savePassword, connectAutomaticallyAtStartup: connectAutomaticallyAtStartup, ), ); From a8caf782f3e27d943fecf80165ad43151cf0253b Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 25 Nov 2023 10:41:19 -0300 Subject: [PATCH 08/11] ui: update add server wizard --- lib/l10n/app_en.arb | 5 +- lib/l10n/app_fr.arb | 5 +- lib/l10n/app_pl.arb | 5 +- lib/l10n/app_pt.arb | 5 +- lib/providers/server_provider.dart | 8 +- .../device_grid/desktop/desktop_sidebar.dart | 2 +- lib/widgets/servers/add_server.dart | 908 +++++++++--------- lib/widgets/settings/shared/server_tile.dart | 2 +- 8 files changed, 449 insertions(+), 491 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4965e83e..3876ec15 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3,7 +3,7 @@ "welcome": "Welcome", "welcomeDescription": "Welcome to the Bluecherry Surveillance DVR!\nLet's connect to your DVR server quickly.", "configure": "Configure a DVR Server", - "configureDescription": "Setup a connection to your remote DVR server", + "configureDescription": "Setup a connection to your remote DVR server. You can connect to any number of servers from anywhere in the world.", "hostname": "Hostname", "hostnameExample": "demo.bluecherry.app", "port": "Port", @@ -18,11 +18,12 @@ "useDefault": "Use Default", "connect": "Connect", "connectAutomaticallyAtStartup": "Connect automatically at startup", + "connectAutomaticallyAtStartupDescription": "If enabled, the server will be automatically connected when the app starts.", "skip": "Skip", "cancel": "Cancel", "letsGo": "Let's Go!", "finish": "Finish", - "letsGoDescription": "Here's some tips on how to get started", + "letsGoDescription": "Here's some tips on how to get started:", "projectName": "Bluecherry", "projectDescription": "Powerful Video Surveillance Software", "website": "Website", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 98ec57af..5f041376 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3,7 +3,7 @@ "welcome": "Bienvenue", "welcomeDescription": "Bienvenue sur le DVR de surveillance Bluecherry !\nConnectons-nous rapidement à votre serveur DVR.", "configure": "Configurer un serveur DVR", - "configureDescription": "Configurons une connexion à votre serveur DVR distant", + "configureDescription": "Configurons une connexion à votre serveur DVR distant. You can connect to any number of servers from anywhere in the world.", "hostname": "Nom d'hôte", "hostnameExample": "demo.bluecherry.app", "port": "Port", @@ -18,11 +18,12 @@ "useDefault": "Par défaut", "connect": "Connecter", "connectAutomaticallyAtStartup": "Connecter automatiquement au démarrage", + "connectAutomaticallyAtStartupDescription": "If enabled, the app will automatically connect to the server when it starts.", "skip": "Sauter", "cancel": "Annuler", "letsGo": "C'EST PARTI!", "finish": "Terminé", - "letsGoDescription": "Voici quelques astuces pour bien commencer", + "letsGoDescription": "Voici quelques astuces pour bien commencer:", "projectName": "Bluecherry", "projectDescription": "Puissant logiciel de surveillance vidéo", "website": "Site internet", diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 2de0c591..f23cd827 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -3,7 +3,7 @@ "welcome": "Witamy", "welcomeDescription": "Witamy w systemie monitoringu Blueberry!\nPodłącz się do serwera DVR.", "configure": "Konfiguracja serwera DVR", - "configureDescription": "Ustawienia połączenia ze zdalnym serwerem DVR", + "configureDescription": "Ustawienia połączenia ze zdalnym serwerem DVR. You can connect to any number of servers from anywhere in the world.", "hostname": "Nazwa hosta", "hostnameExample": "demo.bluecherry.app", "port": "Port", @@ -18,11 +18,12 @@ "useDefault": "Użyj wartości domyślnych", "connect": "Połącz", "connectAutomaticallyAtStartup": "Połącz automatycznie przy uruchomieniu", + "connectAutomaticallyAtStartupDescription": "If enabled, the app will automatically connect to the server when it starts.", "skip": "Pomiń", "cancel": "Anuluj", "letsGo": "Do dzieła!", "finish": "Zakończ", - "letsGoDescription": "Kilka porad jak zacząć", + "letsGoDescription": "Kilka porad jak zacząć:", "projectName": "Bluecherry", "projectDescription": "Oprogramowanie do monitoringu wizyjnego", "website": "Strona domowa", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 698697bc..a58edfb4 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -3,7 +3,7 @@ "welcome": "Bem vindo!", "welcomeDescription": "Sea bem vindo ao Bluecherry Surveillance DVR!\nVamos conectar ao seu servidor DVR em um instante!", "configure": "Configure um Servidor DVR", - "configureDescription": "Configure uma conexão com seu servidor DVR remoto", + "configureDescription": "Configure uma conexão com seu servidor DVR remoto. Você pode se conectar a quantos servidores quiser de qualquer lugar do mundo.", "hostname": "Hostname", "hostnameExample": "demo.bluecherry.app", "port": "Porta", @@ -18,11 +18,12 @@ "useDefault": "Usar Padrão", "connect": "Conectar", "connectAutomaticallyAtStartup": "Conectar automaticamente ao iniciar", + "connectAutomaticallyAtStartupDescription": "Se ativado, o servidor será conectado automaticamente quando o aplicativo for iniciado.", "skip": "Pular", "cancel": "Cancelar", "letsGo": "Vamos lá!", "finish": "Concluir", - "letsGoDescription": "Aqui algumas dicas de como começar", + "letsGoDescription": "Aqui algumas dicas de como começar:", "projectName": "Bluecherry", "projectDescription": "Powerful Video Surveillance Software", "website": "Website", diff --git a/lib/providers/server_provider.dart b/lib/providers/server_provider.dart index a2e1469e..07cab715 100644 --- a/lib/providers/server_provider.dart +++ b/lib/providers/server_provider.dart @@ -67,7 +67,7 @@ class ServersProvider extends ChangeNotifier { await _restore(); } - refreshDevices(); + refreshDevices(startup: true); } /// Adds a new [Server] to the cache. @@ -158,9 +158,13 @@ class ServersProvider extends ChangeNotifier { } /// If [ids] is provided, only the provided ids will be refreshed - Future> refreshDevices([List? ids]) async { + Future> refreshDevices({ + bool startup = false, + List? ids, + }) async { await Future.wait(servers.map((server) async { if (ids != null && !ids.contains(server.id)) return; + if (startup && !server.connectAutomaticallyAtStartup) return; if (!loadingServer.contains(server.id)) { loadingServer.add(server.id); diff --git a/lib/widgets/device_grid/desktop/desktop_sidebar.dart b/lib/widgets/device_grid/desktop/desktop_sidebar.dart index 17e8e673..a2642a77 100644 --- a/lib/widgets/device_grid/desktop/desktop_sidebar.dart +++ b/lib/widgets/device_grid/desktop/desktop_sidebar.dart @@ -95,7 +95,7 @@ class _DesktopSidebarState extends State { icon: const Icon(Icons.refresh), tooltip: loc.refreshServer, onPressed: () => - servers.refreshDevices([server.id]), + servers.refreshDevices(ids: [server.id]), ); } else if (isSidebarHovering && devices.isNotEmpty) { return IconButton( diff --git a/lib/widgets/servers/add_server.dart b/lib/widgets/servers/add_server.dart index 32fe5dbd..914ba180 100644 --- a/lib/widgets/servers/add_server.dart +++ b/lib/widgets/servers/add_server.dart @@ -41,16 +41,21 @@ class AddServerWizard extends StatefulWidget { class _AddServerWizardState extends State { Server? server; - final PageController controller = PageController( + final controller = PageController( initialPage: HomeProvider.instance.automaticallyGoToAddServersScreen ? 1 : 0, ); + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final loc = AppLocalizations.of(context); - final textDirection = Directionality.of(context); return AnnotatedRegion( value: const SystemUiOverlayStyle( @@ -58,199 +63,202 @@ class _AddServerWizardState extends State { statusBarIconBrightness: Brightness.light, statusBarBrightness: Brightness.dark, ), - child: PageView( - controller: controller, - physics: const NeverScrollableScrollPhysics(), - children: [ - Stack(children: [ - Positioned.fill( - child: Image.asset( - 'assets/images/background.webp', - fit: BoxFit.cover, - width: MediaQuery.sizeOf(context).width, - height: MediaQuery.sizeOf(context).height, - ), - ), - Container( - alignment: AlignmentDirectional.center, - child: Card( - color: theme.cardColor, - elevation: 4.0, - clipBehavior: Clip.antiAlias, - margin: const EdgeInsetsDirectional.symmetric( - horizontal: 16.0, - vertical: 16.0, - ).resolve(textDirection) + - MediaQuery.paddingOf(context), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsetsDirectional.all(16.0), - child: Column(children: [ - Image.asset( - 'assets/images/icon.png', - height: 124.0, - width: 124.0, - fit: BoxFit.contain, - ), - const SizedBox(height: 24.0), - Text( - loc.projectName, - style: theme.textTheme.displayLarge?.copyWith( - fontSize: 36.0, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4.0), - Text( - loc.projectDescription, - style: theme.textTheme.headlineSmall, - ), - const SizedBox(height: 16.0), - Container( - alignment: AlignmentDirectional.centerEnd, - padding: const EdgeInsetsDirectional.all(8.0), - child: Row(children: [ - const Spacer(), - MaterialButton( - onPressed: () { - launchUrl( - Uri.https( - 'www.bluecherrydvr.com', - '/', - ), - mode: LaunchMode.externalApplication, - ); - }, - child: Text( - loc.website, - style: theme.textTheme.headlineMedium?.copyWith( - color: theme.colorScheme.primary, - ), - ), - ), - MaterialButton( - onPressed: () { - launchUrl( - Uri.https( - 'www.bluecherrydvr.com', - '/product/v3license/', - ), - mode: LaunchMode.externalApplication, - ); - }, - child: Text( - loc.purchase, - style: theme.textTheme.headlineMedium?.copyWith( - color: theme.colorScheme.primary, - ), - ), + child: DecoratedBox( + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/images/background.webp'), + fit: BoxFit.cover, + ), + ), + child: PageView( + controller: controller, + physics: const NeverScrollableScrollPhysics(), + children: [ + Stack(alignment: Alignment.center, children: [ + IntrinsicWidth( + child: Container( + constraints: BoxConstraints( + minWidth: MediaQuery.sizeOf(context).width / 2.5, + ), + alignment: AlignmentDirectional.center, + child: Card( + color: theme.cardColor, + elevation: 4.0, + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.all(16) + + MediaQuery.paddingOf(context), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsetsDirectional.all(16.0), + child: + Column(mainAxisSize: MainAxisSize.min, children: [ + Image.asset( + 'assets/images/icon.png', + height: 124.0, + width: 124.0, + fit: BoxFit.contain, ), - ]), - ), - const Divider(thickness: 1.0), - const SizedBox(height: 16.0), - Column( - crossAxisAlignment: - (AppBarTheme.of(context).centerTitle ?? false) - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ + const SizedBox(height: 24.0), Text( - loc.welcome, + loc.projectName, style: theme.textTheme.displayLarge?.copyWith( - fontSize: 20.0, + fontSize: 36.0, fontWeight: FontWeight.w600, ), ), - const SizedBox(height: 8.0), + const SizedBox(height: 4.0), Text( - loc.welcomeDescription, + loc.projectDescription, style: theme.textTheme.headlineSmall, ), - ], + const SizedBox(height: 16.0), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () { + launchUrl( + Uri.https( + 'www.bluecherrydvr.com', + '/', + ), + mode: LaunchMode.externalApplication, + ); + }, + child: Text(loc.website), + ), + const SizedBox(width: 8.0), + TextButton( + onPressed: () { + launchUrl( + Uri.https( + 'www.bluecherrydvr.com', + '/product/v3license/', + ), + mode: LaunchMode.externalApplication, + ); + }, + child: Text(loc.purchase), + ), + ], + ), + const Divider(thickness: 1.0), + const SizedBox(height: 16.0), + Column( + crossAxisAlignment: isCupertino + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + Text( + loc.welcome, + style: theme.textTheme.displayLarge?.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8.0), + Text( + loc.welcomeDescription, + style: theme.textTheme.headlineSmall, + ), + ], + ), + ]), ), - ]), - ), - const SizedBox(height: 16.0), - Material( - // color: theme.colorScheme.primaryContainer, - child: InkWell( - onTap: () { - controller.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - child: Container( - alignment: AlignmentDirectional.center, - width: double.infinity, - height: 56.0, - child: Text( - loc.letsGo.toUpperCase(), - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontSize: 16.0, - fontWeight: FontWeight.w600, + const SizedBox(height: 16.0), + Material( + child: InkWell( + onTap: () { + controller.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + child: Container( + alignment: AlignmentDirectional.center, + width: double.infinity, + height: 56.0, + child: Text( + loc.letsGo.toUpperCase(), + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontSize: 16.0, + fontWeight: FontWeight.w600, + ), + ), ), ), ), - ), + ], ), - ], + ), ), ), - ), - if (Scaffold.hasDrawer(context)) - PositionedDirectional( - top: MediaQuery.paddingOf(context).top, - start: 0, - child: const Material( - type: MaterialType.transparency, - child: SizedBox( - height: kToolbarHeight, - width: kToolbarHeight, - child: UnityDrawerButton(iconColor: Colors.white), + if (Scaffold.hasDrawer(context)) + PositionedDirectional( + top: MediaQuery.paddingOf(context).top, + start: 0, + child: const Material( + type: MaterialType.transparency, + child: SizedBox( + height: kToolbarHeight, + width: kToolbarHeight, + child: UnityDrawerButton(iconColor: Colors.white), + ), ), ), + ]), + Center( + child: ConfigureDVRServerScreen( + onBack: () { + controller.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + onNext: () { + controller.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + onServerChange: (server) => + setState(() => this.server = server), + server: server, ), - ]), - ConfigureDVRServerScreen( - controller: controller, - setServer: setServer, - getServer: getServer, - ), - LetsGoScreen( - controller: controller, - getServer: getServer, - onFinish: widget.onFinish, - ), - ], + ), + LetsGoScreen( + server: server, + onFinish: widget.onFinish, + onBack: () { + controller.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + ), + ], + ), ), ); } - - void setServer(Server server) { - setState(() { - this.server = server; - }); - } - - Server? getServer() => server; } class ConfigureDVRServerScreen extends StatefulWidget { - final PageController controller; - final ValueChanged setServer; - final Server? Function() getServer; + final VoidCallback onBack; + final VoidCallback onNext; + final ValueChanged onServerChange; + final Server? server; const ConfigureDVRServerScreen({ super.key, - required this.controller, - required this.setServer, - required this.getServer, + required this.onBack, + required this.onNext, + required this.onServerChange, + required this.server, }); @override @@ -265,25 +273,39 @@ class _ConfigureDVRServerScreenState extends State { final nameController = TextEditingController(); final usernameController = TextEditingController(); final passwordController = TextEditingController(); - bool showingPassword = false; - bool nameTextFieldEverFocused = false; + bool _nameTextFieldEverFocused = false; bool connectAutomaticallyAtStartup = true; bool disableFinishButton = false; + bool showingPassword = false; + final formKey = GlobalKey(); + final finishFocusNode = FocusNode(); String getServerHostname(String text) { if (Uri.parse(text).scheme.isEmpty) text = 'https://$text'; return Uri.parse(text).host; } + @override + void dispose() { + hostnameController.dispose(); + portController.dispose(); + rtspPortController.dispose(); + nameController.dispose(); + usernameController.dispose(); + passwordController.dispose(); + finishFocusNode.dispose(); + super.dispose(); + } + @override void initState() { super.initState(); hostnameController.addListener(() { final hostname = getServerHostname(hostnameController.text); - if (!nameTextFieldEverFocused && hostname.isNotEmpty) { - nameController.text = hostname; + if (!_nameTextFieldEverFocused && hostname.isNotEmpty) { + nameController.text = hostname.split('.').first; } }); } @@ -366,7 +388,7 @@ class _ConfigureDVRServerScreenState extends State { } return null; }, - onTap: () => nameTextFieldEverFocused = true, + onTap: () => _nameTextFieldEverFocused = true, controller: nameController, textCapitalization: TextCapitalization.words, keyboardType: TextInputType.name, @@ -435,167 +457,186 @@ class _ConfigureDVRServerScreenState extends State { return PopScope( canPop: false, onPopInvoked: (didPop) async { - if (widget.getServer() == null) { - widget.controller.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); + if (widget.server == null) { + widget.onBack(); } }, - child: Scaffold( - appBar: AppBar( - leading: BackButton( - onPressed: () { - widget.controller.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - FocusScope.of(context).unfocus(); - }, - ), - backgroundColor: theme.brightness == Brightness.light - ? theme.colorScheme.primary - : theme.cardColor, - systemOverlayStyle: const SystemUiOverlayStyle( - statusBarColor: Colors.white12, - statusBarIconBrightness: Brightness.light, - statusBarBrightness: Brightness.dark, - ), - title: Text( - loc.configure, - style: const TextStyle( - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(32.0), - child: Container( - height: 32.0, - padding: const EdgeInsetsDirectional.only(start: 16.0), - alignment: theme.appBarTheme.centerTitle ?? false - ? AlignmentDirectional.topCenter - : AlignmentDirectional.topStart, - child: Text( - loc.configureDescription, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.headlineMedium - ?.copyWith(color: Colors.white.withOpacity(0.87)), - ), - ), + child: IntrinsicWidth( + child: Container( + constraints: BoxConstraints( + minWidth: MediaQuery.sizeOf(context).width / 2.5, ), - ), - body: Card( - elevation: 4.0, margin: const EdgeInsetsDirectional.all(16.0), - child: Padding( - padding: const EdgeInsetsDirectional.symmetric( - horizontal: 16.0, - vertical: 24.0, - ), - child: FocusTraversalGroup( - policy: OrderedTraversalPolicy(), - child: Form( - key: formKey, - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - flex: 5, - child: FocusTraversalOrder( - order: const NumericFocusOrder(0), - child: hostnameField, + child: Card( + elevation: 4.0, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsetsDirectional.symmetric( + horizontal: 16.0, + vertical: 24.0, + ), + child: FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: Form( + key: formKey, + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Row(children: [ + IconButton( + icon: const BackButtonIcon(), + tooltip: + MaterialLocalizations.of(context).backButtonTooltip, + onPressed: () { + widget.onBack(); + FocusScope.of(context).unfocus(); + }, ), - ), - const SizedBox(width: 16.0), - Expanded( - flex: 2, - child: FocusTraversalOrder( - order: const NumericFocusOrder(1), - child: portField, + const SizedBox(width: 8.0), + Text( + loc.configure, + style: const TextStyle( + fontWeight: FontWeight.w500, + color: Colors.white, + ), ), + ]), + const SizedBox(height: 12.0), + Text( + loc.configureDescription, + style: theme.textTheme.headlineMedium + ?.copyWith(color: Colors.white.withOpacity(0.87)), ), - const SizedBox(width: 16.0), - Expanded( - flex: 2, - child: FocusTraversalOrder( - order: const NumericFocusOrder(2), - child: rtspPortField, - ), + const SizedBox(height: 20.0), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 5, + child: FocusTraversalOrder( + order: const NumericFocusOrder(0), + child: hostnameField, + ), + ), + const SizedBox(width: 16.0), + Expanded( + flex: 2, + child: FocusTraversalOrder( + order: const NumericFocusOrder(1), + child: portField, + ), + ), + const SizedBox(width: 16.0), + Expanded( + flex: 2, + child: FocusTraversalOrder( + order: const NumericFocusOrder(2), + child: rtspPortField, + ), + ), + ], ), - ]), - const SizedBox(height: 16.0), - FocusTraversalOrder( - order: const NumericFocusOrder(3), - child: nameField, - ), - const SizedBox(height: 16.0), - Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: FocusTraversalOrder( - order: const NumericFocusOrder(5), - child: usernameField, + const SizedBox(height: 16.0), + FocusTraversalOrder( + order: const NumericFocusOrder(3), + child: nameField, + ), + const SizedBox(height: 16.0), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: FocusTraversalOrder( + order: const NumericFocusOrder(5), + child: usernameField, + ), + ), + const SizedBox(width: 8.0), + Padding( + padding: const EdgeInsetsDirectional.only(top: 8.0), + child: FocusTraversalOrder( + order: NumericFocusOrder(isMobilePlatform ? -1 : 4), + child: MaterialButton( + onPressed: disableFinishButton + ? null + : () { + usernameController.text = + kDefaultUsername; + passwordController.text = + kDefaultPassword; + finishFocusNode.requestFocus(); + }, + child: Text(loc.useDefault.toUpperCase()), + ), + ), + ), + ], + ), + const SizedBox(height: 16.0), + FocusTraversalOrder( + order: const NumericFocusOrder(6), + child: passwordField, + ), + const SizedBox(height: 8.0), + FocusTraversalOrder( + order: const NumericFocusOrder(7), + child: CheckboxListTile.adaptive( + value: connectAutomaticallyAtStartup, + onChanged: (value) => setState( + () => connectAutomaticallyAtStartup = value ?? true, + ), + title: Text(loc.connectAutomaticallyAtStartup), + dense: true, + controlAffinity: ListTileControlAffinity.leading, + secondary: Tooltip( + message: loc.connectAutomaticallyAtStartupDescription, + child: Icon( + Icons.info_outline, + color: theme.colorScheme.secondary, + size: 20.0, + ), + ), ), ), - const SizedBox(width: 8.0), - Padding( - padding: const EdgeInsetsDirectional.only(top: 8.0), - child: FocusTraversalOrder( - order: NumericFocusOrder(isMobilePlatform ? -1 : 4), + const SizedBox(height: 16.0), + Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + if (disableFinishButton) + const SizedBox( + height: 24.0, + width: 24.0, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2.0, + ), + ), + FocusTraversalOrder( + order: const NumericFocusOrder(9), child: MaterialButton( onPressed: disableFinishButton ? null : () { - usernameController.text = kDefaultUsername; - passwordController.text = kDefaultPassword; + widget.onNext(); }, - child: Text(loc.useDefault.toUpperCase()), - ), - ), - ), - ]), - const SizedBox(height: 16.0), - FocusTraversalOrder( - order: const NumericFocusOrder(6), - child: passwordField, - ), - const SizedBox(height: 16.0), - Row(mainAxisAlignment: MainAxisAlignment.end, children: [ - if (disableFinishButton) - const SizedBox( - height: 24.0, - width: 24.0, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2.0, + child: Padding( + padding: const EdgeInsetsDirectional.all(8.0), + child: Text(loc.skip.toUpperCase()), + ), ), ), - MaterialButton( - onPressed: disableFinishButton - ? null - : () { - widget.controller.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - child: Padding( - padding: const EdgeInsetsDirectional.all(8.0), - child: Text(loc.skip.toUpperCase()), - ), - ), - FocusTraversalOrder( - order: const NumericFocusOrder(7), - child: MaterialButton( - onPressed: - disableFinishButton ? null : () => finish(context), - child: Padding( - padding: const EdgeInsetsDirectional.all(8.0), - child: Text(loc.finish.toUpperCase()), + const SizedBox(width: 6.0), + FocusTraversalOrder( + order: const NumericFocusOrder(8), + child: FilledButton( + onPressed: disableFinishButton + ? null + : () => finish(context), + focusNode: finishFocusNode, + child: Padding( + padding: const EdgeInsetsDirectional.all(8.0), + child: Text(loc.finish.toUpperCase()), + ), ), ), - ), + ]), ]), - ]), + ), ), ), ), @@ -649,12 +690,9 @@ class _ConfigureDVRServerScreenState extends State { focusScope.unfocus(); if (server.serverUUID != null && server.cookie != null) { - widget.setServer(server); + widget.onServerChange(server); await ServersProvider.instance.add(server); - widget.controller.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); + widget.onNext(); } else { if (mounted) { showDialog( @@ -679,193 +717,105 @@ class _ConfigureDVRServerScreenState extends State { } } -class LetsGoScreen extends StatefulWidget { - final PageController controller; - final Server? Function() getServer; +class LetsGoScreen extends StatelessWidget { + final VoidCallback onBack; + final Server? server; final VoidCallback onFinish; const LetsGoScreen({ super.key, - required this.controller, - required this.getServer, + required this.onBack, + required this.server, required this.onFinish, }); - @override - State createState() => _LetsGoScreenState(); -} - -class _LetsGoScreenState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); final loc = AppLocalizations.of(context); - final server = widget.getServer(); return PopScope( canPop: false, - child: Scaffold( - appBar: showIf( - isMobile, - child: AppBar( - leading: server != null - ? null - : BackButton( - color: Colors.white, - onPressed: () { - widget.controller.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - FocusScope.of(context).unfocus(); - }, - ), - systemOverlayStyle: const SystemUiOverlayStyle( - statusBarColor: Colors.white12, - statusBarIconBrightness: Brightness.light, - statusBarBrightness: Brightness.dark, + child: Center( + child: IntrinsicWidth( + child: Container( + margin: const EdgeInsetsDirectional.all(16.0), + constraints: BoxConstraints( + minWidth: MediaQuery.sizeOf(context).width / 2.5, ), - backgroundColor: theme.brightness == Brightness.light - ? theme.colorScheme.primary - : theme.cardColor, - title: Text( - loc.letsGo, - style: const TextStyle( - fontWeight: FontWeight.w500, - color: Colors.white, - ), - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(32.0), - child: Container( - height: 32.0, - padding: const EdgeInsetsDirectional.only(start: 16.0), - alignment: theme.appBarTheme.centerTitle ?? false - ? AlignmentDirectional.topCenter - : AlignmentDirectional.topStart, - child: Text( - loc.letsGoDescription, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.headlineMedium - ?.copyWith(color: Colors.white.withOpacity(0.87)), - ), - ), - ), - ), - ), - body: ListView(children: [ - const SizedBox(height: 8.0), - if (server != null) - Card( - elevation: 4.0, - color: Color.alphaBlend( - Colors.green.withOpacity(0.2), - theme.cardColor, - ), - margin: const EdgeInsetsDirectional.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: Padding( - padding: const EdgeInsetsDirectional.all(16.0), - child: Row(children: [ - Icon( - Icons.check, - color: Colors.green.shade400, - ), - const SizedBox(width: 16.0), - Expanded( - child: Text(loc.serverAdded), - ), - ]), - ), - ), - Card( - elevation: 4.0, - margin: const EdgeInsetsDirectional.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: Padding( - padding: const EdgeInsetsDirectional.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsetsDirectional.symmetric( - vertical: 8.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (server != null) + Card( + elevation: 4.0, + margin: const EdgeInsetsDirectional.only(bottom: 8.0), + color: Color.alphaBlend( + Colors.green.withOpacity(0.2), + theme.cardColor, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text(' • '), - const SizedBox(width: 4.0), - Expanded( - child: Text(loc.tip0), - ), - ], - ), - ), - Padding( - padding: const EdgeInsetsDirectional.symmetric( - vertical: 8.0, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text(' • '), - const SizedBox(width: 4.0), - Expanded( - child: Text(loc.tip1), + child: Padding( + padding: const EdgeInsetsDirectional.all(16.0), + child: Row(children: [ + Icon( + Icons.check, + color: Colors.green.shade400, ), - ], - ), - ), - Padding( - padding: const EdgeInsetsDirectional.symmetric( - vertical: 8.0, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text(' • '), - const SizedBox(width: 4.0), + const SizedBox(width: 16.0), Expanded( - child: Text(loc.tip2), + child: Text(loc.serverAdded), ), - ], + ]), ), ), - Padding( - padding: const EdgeInsetsDirectional.symmetric( - vertical: 8.0, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text(' • '), - const SizedBox(width: 4.0), - Expanded( - child: Text(loc.tip3), - ), - ], + Card( + elevation: 4.0, + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsetsDirectional.all(16.0), + child: SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + loc.letsGoDescription, + style: theme.textTheme.headlineMedium?.copyWith( + color: Colors.white.withOpacity(0.87)), + ), + ...[loc.tip0, loc.tip1, loc.tip2, loc.tip3] + .map((tip) { + return Padding( + padding: const EdgeInsetsDirectional.only( + top: 8.0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text(' • '), + const SizedBox(width: 4.0), + Expanded( + child: Text(tip), + ), + ], + ), + ); + }), + ], + ), ), ), - ], - ), + ), + const SizedBox(height: 8.0), + FloatingActionButton.extended( + onPressed: onFinish, + label: Text(loc.finish.toUpperCase()), + icon: const Icon(Icons.check), + ), + ], ), ), - const SizedBox(height: 8.0), - ]), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - widget.onFinish.call(); - }, - label: Text(loc.finish.toUpperCase()), - icon: const Icon(Icons.check), ), - floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, ), ); } diff --git a/lib/widgets/settings/shared/server_tile.dart b/lib/widgets/settings/shared/server_tile.dart index 61dc1ea7..526c7f7e 100644 --- a/lib/widgets/settings/shared/server_tile.dart +++ b/lib/widgets/settings/shared/server_tile.dart @@ -401,7 +401,7 @@ Future showServerMenu({ server.online ? loc.refreshDevices : loc.refreshServer, ), onTap: () async { - servers.refreshDevices([server.id]); + servers.refreshDevices(ids: [server.id]); }, ), ], From a8d5bb8d7a67d2175386866dae05274b9198ae68 Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sat, 25 Nov 2023 10:46:47 -0300 Subject: [PATCH 09/11] fix: dispose resources --- lib/widgets/device_grid/desktop/external_stream.dart | 2 ++ lib/widgets/device_grid/desktop/layout_manager.dart | 6 ++++++ lib/widgets/events_timeline/events_playback.dart | 1 + 3 files changed, 9 insertions(+) diff --git a/lib/widgets/device_grid/desktop/external_stream.dart b/lib/widgets/device_grid/desktop/external_stream.dart index fdbf5a63..3656f04c 100644 --- a/lib/widgets/device_grid/desktop/external_stream.dart +++ b/lib/widgets/device_grid/desktop/external_stream.dart @@ -164,6 +164,8 @@ class _AddExternalStreamDialogState extends State { void dispose() { nameController.dispose(); urlController.dispose(); + rackNameController.dispose(); + serverIpController.dispose(); super.dispose(); } diff --git a/lib/widgets/device_grid/desktop/layout_manager.dart b/lib/widgets/device_grid/desktop/layout_manager.dart index 3095c319..7759cf2f 100644 --- a/lib/widgets/device_grid/desktop/layout_manager.dart +++ b/lib/widgets/device_grid/desktop/layout_manager.dart @@ -560,6 +560,12 @@ class _EditLayoutDialogState extends State { late final controller = TextEditingController(text: widget.layout.name); late int selected = widget.layout.type.index; + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); diff --git a/lib/widgets/events_timeline/events_playback.dart b/lib/widgets/events_timeline/events_playback.dart index 17f5166c..4ce65616 100644 --- a/lib/widgets/events_timeline/events_playback.dart +++ b/lib/widgets/events_timeline/events_playback.dart @@ -55,6 +55,7 @@ class _EventsPlaybackState extends EventsScreenState { @override void dispose() { timeline?.dispose(); + focusNode.dispose(); super.dispose(); } From 6fd23b8b2167cb2111e1a03f303e43263dbd235c Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sun, 26 Nov 2023 09:29:44 -0300 Subject: [PATCH 10/11] chore: split add server wizard into AddServerInfoScreen --- lib/widgets/servers/add_server.dart | 328 ++++++++++++++-------------- 1 file changed, 163 insertions(+), 165 deletions(-) diff --git a/lib/widgets/servers/add_server.dart b/lib/widgets/servers/add_server.dart index 914ba180..85a85aae 100644 --- a/lib/widgets/servers/add_server.dart +++ b/lib/widgets/servers/add_server.dart @@ -52,11 +52,22 @@ class _AddServerWizardState extends State { super.dispose(); } + void _onNext() { + controller.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + void _onBack() { + controller.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final loc = AppLocalizations.of(context); - return AnnotatedRegion( value: const SystemUiOverlayStyle( statusBarColor: Colors.white12, @@ -70,177 +81,164 @@ class _AddServerWizardState extends State { fit: BoxFit.cover, ), ), - child: PageView( - controller: controller, - physics: const NeverScrollableScrollPhysics(), - children: [ - Stack(alignment: Alignment.center, children: [ - IntrinsicWidth( - child: Container( - constraints: BoxConstraints( - minWidth: MediaQuery.sizeOf(context).width / 2.5, + child: SafeArea( + child: Stack(children: [ + PageView( + controller: controller, + physics: const NeverScrollableScrollPhysics(), + children: [ + Center(child: AddServerInfoScreen(onNext: _onNext)), + Center( + child: ConfigureDVRServerScreen( + onBack: _onBack, + onNext: _onNext, + onServerChange: (server) => + setState(() => this.server = server), + server: server, ), - alignment: AlignmentDirectional.center, - child: Card( - color: theme.cardColor, - elevation: 4.0, - clipBehavior: Clip.antiAlias, - margin: const EdgeInsets.all(16) + - MediaQuery.paddingOf(context), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsetsDirectional.all(16.0), - child: - Column(mainAxisSize: MainAxisSize.min, children: [ - Image.asset( - 'assets/images/icon.png', - height: 124.0, - width: 124.0, - fit: BoxFit.contain, - ), - const SizedBox(height: 24.0), - Text( - loc.projectName, - style: theme.textTheme.displayLarge?.copyWith( - fontSize: 36.0, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4.0), - Text( - loc.projectDescription, - style: theme.textTheme.headlineSmall, - ), - const SizedBox(height: 16.0), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () { - launchUrl( - Uri.https( - 'www.bluecherrydvr.com', - '/', - ), - mode: LaunchMode.externalApplication, - ); - }, - child: Text(loc.website), - ), - const SizedBox(width: 8.0), - TextButton( - onPressed: () { - launchUrl( - Uri.https( - 'www.bluecherrydvr.com', - '/product/v3license/', - ), - mode: LaunchMode.externalApplication, - ); - }, - child: Text(loc.purchase), - ), - ], - ), - const Divider(thickness: 1.0), - const SizedBox(height: 16.0), - Column( - crossAxisAlignment: isCupertino - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ - Text( - loc.welcome, - style: theme.textTheme.displayLarge?.copyWith( - fontSize: 20.0, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8.0), - Text( - loc.welcomeDescription, - style: theme.textTheme.headlineSmall, - ), - ], - ), - ]), - ), - const SizedBox(height: 16.0), - Material( - child: InkWell( - onTap: () { - controller.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - child: Container( - alignment: AlignmentDirectional.center, - width: double.infinity, - height: 56.0, - child: Text( - loc.letsGo.toUpperCase(), - style: TextStyle( - color: theme.colorScheme.onPrimaryContainer, - fontSize: 16.0, - fontWeight: FontWeight.w600, - ), - ), - ), + ), + LetsGoScreen( + server: server, + onFinish: widget.onFinish, + onBack: _onBack, + ), + ], + ), + if (Scaffold.hasDrawer(context)) + PositionedDirectional( + top: MediaQuery.paddingOf(context).top, + start: 0, + child: const Material( + type: MaterialType.transparency, + color: Colors.amber, + child: SizedBox( + height: kToolbarHeight, + width: kToolbarHeight, + child: UnityDrawerButton(iconColor: Colors.white), + ), + ), + ), + ]), + ), + ), + ); + } +} + +class AddServerInfoScreen extends StatelessWidget { + final VoidCallback onNext; + + const AddServerInfoScreen({super.key, required this.onNext}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context); + + return IntrinsicWidth( + child: Container( + constraints: BoxConstraints( + minWidth: MediaQuery.sizeOf(context).width / 2.5, + ), + alignment: AlignmentDirectional.center, + child: Card( + color: theme.cardColor, + elevation: 4.0, + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.all(16) + MediaQuery.paddingOf(context), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsetsDirectional.all(16.0), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Image.asset( + 'assets/images/icon.png', + height: 124.0, + width: 124.0, + fit: BoxFit.contain, + ), + const SizedBox(height: 24.0), + Text( + loc.projectName, + style: theme.textTheme.displayLarge?.copyWith( + fontSize: 36.0, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4.0), + Text( + loc.projectDescription, + style: theme.textTheme.headlineSmall, + ), + const SizedBox(height: 16.0), + Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + TextButton( + onPressed: () { + launchUrl( + Uri.https( + 'www.bluecherrydvr.com', + '/', ), - ), - ], + mode: LaunchMode.externalApplication, + ); + }, + child: Text(loc.website), + ), + const SizedBox(width: 8.0), + TextButton( + onPressed: () { + launchUrl( + Uri.https( + 'www.bluecherrydvr.com', + '/product/v3license/', + ), + mode: LaunchMode.externalApplication, + ); + }, + child: Text(loc.purchase), + ), + ]), + const Divider(thickness: 1.0), + const SizedBox(height: 16.0), + Text( + loc.welcome, + style: theme.textTheme.displayLarge?.copyWith( + fontSize: 20.0, + fontWeight: FontWeight.w600, ), + textAlign: TextAlign.center, ), - ), + const SizedBox(height: 8.0), + Text( + loc.welcomeDescription, + style: theme.textTheme.headlineSmall, + textAlign: TextAlign.center, + ), + ]), ), - if (Scaffold.hasDrawer(context)) - PositionedDirectional( - top: MediaQuery.paddingOf(context).top, - start: 0, - child: const Material( - type: MaterialType.transparency, - child: SizedBox( - height: kToolbarHeight, - width: kToolbarHeight, - child: UnityDrawerButton(iconColor: Colors.white), + const SizedBox(height: 16.0), + Material( + child: InkWell( + onTap: onNext, + child: Container( + alignment: AlignmentDirectional.center, + width: double.infinity, + height: 56.0, + child: Text( + loc.letsGo.toUpperCase(), + style: TextStyle( + color: theme.colorScheme.onPrimaryContainer, + fontSize: 16.0, + fontWeight: FontWeight.w600, + ), ), ), ), - ]), - Center( - child: ConfigureDVRServerScreen( - onBack: () { - controller.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - onNext: () { - controller.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - onServerChange: (server) => - setState(() => this.server = server), - server: server, ), - ), - LetsGoScreen( - server: server, - onFinish: widget.onFinish, - onBack: () { - controller.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - ), - ], + ], + ), ), ), ); From b58a6dcc17c5f7b5c9dcec6d7d7613be1ba709ca Mon Sep 17 00:00:00 2001 From: Bruno D'Luka Date: Sun, 26 Nov 2023 09:34:13 -0300 Subject: [PATCH 11/11] chore: use Link instead of launchUrl whenever possible --- .../device_grid/desktop/stream_data.dart | 12 ++++--- lib/widgets/servers/add_server.dart | 35 +++++++++---------- lib/widgets/settings/shared/update.dart | 29 ++++++++------- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/lib/widgets/device_grid/desktop/stream_data.dart b/lib/widgets/device_grid/desktop/stream_data.dart index 47a72362..2bf30605 100644 --- a/lib/widgets/device_grid/desktop/stream_data.dart +++ b/lib/widgets/device_grid/desktop/stream_data.dart @@ -30,6 +30,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:provider/provider.dart'; import 'package:unity_video_player/unity_video_player.dart'; +import 'package:url_launcher/link.dart'; import 'package:url_launcher/url_launcher.dart'; Future showStreamDataDialog( @@ -326,11 +327,14 @@ class _StreamDataState extends State { if (widget.device.externalData?.serverIp != null) Padding( padding: const EdgeInsetsDirectional.only(end: 8.0), - child: TextButton( - onPressed: () { - launchUrl(widget.device.externalData!.serverIp!); + child: Link( + uri: widget.device.externalData!.serverIp!, + builder: (context, open) { + return TextButton( + onPressed: open, + child: const Text('Open server'), + ); }, - child: const Text('Open server'), ), ), const Spacer(), diff --git a/lib/widgets/servers/add_server.dart b/lib/widgets/servers/add_server.dart index 85a85aae..a5b3e3a3 100644 --- a/lib/widgets/servers/add_server.dart +++ b/lib/widgets/servers/add_server.dart @@ -28,7 +28,7 @@ import 'package:bluecherry_client/widgets/servers/error.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:url_launcher/link.dart'; class AddServerWizard extends StatefulWidget { final VoidCallback onFinish; @@ -174,30 +174,27 @@ class AddServerInfoScreen extends StatelessWidget { ), const SizedBox(height: 16.0), Row(mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton( - onPressed: () { - launchUrl( - Uri.https( - 'www.bluecherrydvr.com', - '/', - ), - mode: LaunchMode.externalApplication, + Link( + uri: Uri.https('www.bluecherrydvr.com', '/'), + builder: (context, open) { + return TextButton( + onPressed: open, + child: Text(loc.website), ); }, - child: Text(loc.website), ), const SizedBox(width: 8.0), - TextButton( - onPressed: () { - launchUrl( - Uri.https( - 'www.bluecherrydvr.com', - '/product/v3license/', - ), - mode: LaunchMode.externalApplication, + Link( + uri: Uri.https( + 'www.bluecherrydvr.com', + '/product/v3license/', + ), + builder: (context, open) { + return TextButton( + onPressed: open, + child: Text(loc.purchase), ); }, - child: Text(loc.purchase), ), ]), const Divider(thickness: 1.0), diff --git a/lib/widgets/settings/shared/update.dart b/lib/widgets/settings/shared/update.dart index c89f097d..ea05ff9a 100644 --- a/lib/widgets/settings/shared/update.dart +++ b/lib/widgets/settings/shared/update.dart @@ -26,7 +26,6 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/link.dart'; -import 'package:url_launcher/url_launcher.dart'; /// The card that displays the update information. class AppUpdateCard extends StatelessWidget { @@ -362,22 +361,22 @@ class About extends StatelessWidget { style: theme.textTheme.displayMedium, ), const SizedBox(height: 8.0), - MaterialButton( - onPressed: () { - launchUrl( - Uri.https('www.bluecherrydvr.com', '/'), - mode: LaunchMode.externalApplication, + Link( + uri: Uri.https('www.bluecherrydvr.com', '/'), + builder: (context, open) { + return MaterialButton( + onPressed: open, + padding: EdgeInsetsDirectional.zero, + minWidth: 0.0, + child: Text( + loc.website, + semanticsLabel: 'www.bluecherrydvr.com', + style: TextStyle( + color: theme.colorScheme.primary, + ), + ), ); }, - padding: EdgeInsetsDirectional.zero, - minWidth: 0.0, - child: Text( - loc.website, - semanticsLabel: 'www.bluecherrydvr.com', - style: TextStyle( - color: theme.colorScheme.primary, - ), - ), ), ]), );