diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 81743af5..9ae1d55a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -243,9 +243,11 @@ jobs: submodules: recursive - name: Install dependencies + # https://docs.flutter.dev/platform-integration/linux/building#prepare-linux-apps-for-distribution + # https://pub.dev/packages/flutter_secure_storage#configure-linux-version run: | sudo apt-get update -y - sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev mpv libmpv-dev dpkg-dev p7zip-full p7zip-rar + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev mpv libmpv-dev dpkg-dev p7zip-full p7zip-rar libsecret-1-dev libjsoncpp-dev - name: Install Flutter uses: subosito/flutter-action@v2.8.0 @@ -381,7 +383,7 @@ jobs: uses: subosito/flutter-action@v2.8.0 with: channel: "stable" - flutter-version: 3.24.0 + flutter-version: 3.27.0 cache: false - name: Initiate Flutter diff --git a/lib/providers/layouts_provider.dart b/lib/providers/layouts_provider.dart index 70d58dcc..fb86726a 100644 --- a/lib/providers/layouts_provider.dart +++ b/lib/providers/layouts_provider.dart @@ -67,7 +67,6 @@ class LayoutsProvider extends UnityProvider { double? get layoutManagerHeight => _layoutManagerHeight; set layoutManagerHeight(double? value) { _layoutManagerHeight = value; - notifyListeners(); save(); } @@ -89,7 +88,8 @@ class LayoutsProvider extends UnityProvider { } @override - Future save({bool notifyListeners = true}) async { + Future save({bool notifyListeners = false}) async { + this.notifyListeners(); await write({ kStorageDesktopLayouts: jsonEncode(layouts.map((layout) => layout.toMap()).toList()), @@ -161,7 +161,7 @@ class LayoutsProvider extends UnityProvider { var previousDevice = layout.devices.firstOrNull; if (previousDevice != null) { layout.devices.clear(); - await _releaseDevice(device); + await maybeReleaseDevice(device); } } @@ -169,12 +169,12 @@ class LayoutsProvider extends UnityProvider { layout.devices.add(device); debugPrint('Added $device'); - notifyListeners(); await save(); } } - Future _releaseDevice(Device device) async { + /// Releases the device if it's not used in any layout. + Future maybeReleaseDevice(Device device) async { if (!UnityPlayers.players.containsKey(device.uuid)) return; if (!layouts .any((layout) => layout.devices.any((d) => d.uuid == device.uuid))) { @@ -188,9 +188,8 @@ class LayoutsProvider extends UnityProvider { debugPrint('Removed $device'); currentLayout.devices.remove(device); - _releaseDevice(device); + maybeReleaseDevice(device); } - notifyListeners(); await save(); } @@ -206,10 +205,9 @@ class LayoutsProvider extends UnityProvider { } for (final device in devices) { - _releaseDevice(device); + maybeReleaseDevice(device); } - notifyListeners(); await save(); } @@ -221,10 +219,9 @@ class LayoutsProvider extends UnityProvider { (d1) => devices.any((d2) => d1.uri == d2.uri), ); for (final device in devices) { - _releaseDevice(device); + maybeReleaseDevice(device); } - notifyListeners(); await save(); } @@ -233,7 +230,6 @@ class LayoutsProvider extends UnityProvider { if (isLayoutLocked(currentLayout)) return; currentLayout.devices.insert(end, currentLayout.devices.removeAt(initial)); - notifyListeners(); await save(); } @@ -244,7 +240,6 @@ class LayoutsProvider extends UnityProvider { } else { debugPrint('$layout already exists'); } - notifyListeners(); await save(); return layouts.indexOf(layout); } @@ -258,10 +253,9 @@ class LayoutsProvider extends UnityProvider { layouts.remove(layout); for (final device in layout.devices) { - _releaseDevice(device); + maybeReleaseDevice(device); } } - notifyListeners(); await save(); } @@ -273,7 +267,7 @@ class LayoutsProvider extends UnityProvider { ..insert(layoutIndex, newLayout); for (final device in oldLayout.devices.where((d) => !newLayout.devices.contains(d))) { - _releaseDevice(device); + maybeReleaseDevice(device); } debugPrint('Replaced $oldLayout at $layoutIndex with $newLayout'); @@ -281,7 +275,6 @@ class LayoutsProvider extends UnityProvider { debugPrint('Layout $oldLayout not found'); } - notifyListeners(); await save(); } @@ -291,7 +284,6 @@ class LayoutsProvider extends UnityProvider { UnityPlayers.players[device.uuid] ??= UnityPlayers.forDevice(device); } - notifyListeners(); await save(); } @@ -316,7 +308,6 @@ class LayoutsProvider extends UnityProvider { } layouts.insert(newIndex, layouts.removeAt(oldIndex)); - notifyListeners(); await save(); } @@ -350,7 +341,6 @@ class LayoutsProvider extends UnityProvider { UnityPlayers.reloadDevice(device); } - notifyListeners(); save(); return device; @@ -359,7 +349,6 @@ class LayoutsProvider extends UnityProvider { Future collapseServer(Server server) async { if (!collapsedServers.contains(server.id)) { collapsedServers.add(server.id); - notifyListeners(); await save(); } } @@ -367,7 +356,6 @@ class LayoutsProvider extends UnityProvider { Future expandServer(Server server) async { if (collapsedServers.contains(server.id)) { collapsedServers.remove(server.id); - notifyListeners(); await save(); } } @@ -377,16 +365,14 @@ class LayoutsProvider extends UnityProvider { Future lockLayout(Layout layout) async { if (!lockedLayouts.contains(layout)) { lockedLayouts.add(layout); - notifyListeners(); - await save(); + await save(notifyListeners: false); } } Future unlockLayout(Layout layout) async { if (lockedLayouts.contains(layout)) { lockedLayouts.remove(layout); - notifyListeners(); - await save(); + await save(notifyListeners: false); } } @@ -400,11 +386,14 @@ class LayoutsProvider extends UnityProvider { bool isLayoutLocked(Layout layout) => lockedLayouts.contains(layout); + /// Sets the volume for all layouts. void setVolume(double volume) { for (final layout in layouts) { layout.setVolume(volume); } + save(); } + /// Mutes all layouts. void mute() => setVolume(0); } diff --git a/lib/screens/events_timeline/desktop/timeline.dart b/lib/screens/events_timeline/desktop/timeline.dart index 686c75d2..a469b1b3 100644 --- a/lib/screens/events_timeline/desktop/timeline.dart +++ b/lib/screens/events_timeline/desktop/timeline.dart @@ -359,7 +359,13 @@ class Timeline extends ChangeNotifier { DateTime get currentDate => date.add(currentPosition); void seekTo(Duration position) { - currentPosition = position; + if (position < Duration.zero) { + currentPosition = Duration.zero; + } else if (position > endPosition) { + currentPosition = endPosition; + } else { + currentPosition = position; + } notifyListeners(); forEachEvent((tile, event) async { @@ -375,8 +381,6 @@ class Timeline extends ChangeNotifier { }); } - // TODO(bdlukaa): Only make it possible to seek between bounds. - // Currently, is is possible to seek before and after the day. /// Seeks forward by [duration] void seekForward([Duration duration = const Duration(seconds: 15)]) => seekTo(currentPosition + duration); diff --git a/lib/screens/events_timeline/desktop/timeline_card.dart b/lib/screens/events_timeline/desktop/timeline_card.dart index e45fa658..18afefcb 100644 --- a/lib/screens/events_timeline/desktop/timeline_card.dart +++ b/lib/screens/events_timeline/desktop/timeline_card.dart @@ -251,7 +251,10 @@ class _TimelineCardState extends State { '/events', arguments: { 'event': currentEvent.event, - 'videoPlayer': widget.tile.videoController, + // Do not pass the video controller to the fullscreen + // view because we don't want to desync the video + // from the Timeline. https://github.com/bluecherrydvr/unity/issues/306 + // 'videoPlayer': widget.tile.videoController, }, ); diff --git a/lib/screens/layouts/desktop/layout_view.dart b/lib/screens/layouts/desktop/layout_view.dart index c25da396..9d2c418f 100644 --- a/lib/screens/layouts/desktop/layout_view.dart +++ b/lib/screens/layouts/desktop/layout_view.dart @@ -357,8 +357,7 @@ class _LayoutViewState extends State { childAspectRatio: kHorizontalAspectRatio, reorderable: widget.onReorder != null, onReorder: widget.onReorder ?? (a, b) {}, - padding: - settings.isImmersiveMode ? EdgeInsets.zero : kGridPadding, + padding: EdgeInsets.zero, children: devices.map((device) { return DesktopDeviceTile(device: device); }).toList(), @@ -410,16 +409,17 @@ class _LayoutViewState extends State { SizedBox( height: 24.0, child: Slider( - value: widget.layout.devices - .map((device) => device.volume) - .findMaxDuplicatedElementInList() - .toDouble(), + value: volume, divisions: 100, label: '${(volume * 100).round()}%', onChanged: (value) async { - widget.layout.setVolume(volume); + await widget.layout.setVolume(value); if (mounted) setState(() {}); }, + onChangeEnd: (value) async { + await widget.layout.setVolume(value); + view.save(); + }, ), ), SquaredIconButton( @@ -507,7 +507,9 @@ class _LayoutViewState extends State { ), ), if (devices.isNotEmpty) - Expanded(child: Center(child: child)) + Expanded( + child: SizedBox.fromSize(size: Size.infinite, child: child), + ) else Expanded( child: Center(child: Text('Add a camera')), diff --git a/lib/screens/layouts/desktop/sidebar.dart b/lib/screens/layouts/desktop/sidebar.dart index d1d5ef4d..56ac2ec7 100644 --- a/lib/screens/layouts/desktop/sidebar.dart +++ b/lib/screens/layouts/desktop/sidebar.dart @@ -36,6 +36,7 @@ class _DesktopSidebarState extends State { var isSidebarHovering = false; var searchQuery = ''; final Map> _servers = >{}; + final _serversEntries = >>[]; @override void didChangeDependencies() { @@ -51,6 +52,8 @@ class _DesktopSidebarState extends State { final devices = server.devices.sorted(searchQuery: searchQuery); _servers[server] = devices; } + _serversEntries.clear(); + _serversEntries.addAll(_servers.entries.toList(growable: false)); } @override @@ -81,26 +84,27 @@ class _DesktopSidebarState extends State { child: MouseRegion( onEnter: (e) => setState(() => isSidebarHovering = true), onExit: (e) => setState(() => isSidebarHovering = false), - // Add another material here because its descendants must be clipped. + // Add another [Material] here because its descendants must be clipped. child: Material( type: MaterialType.transparency, child: CustomScrollView(slivers: [ - ..._servers.entries.toList(growable: false).map((entry) { - final server = entry.key; - final devices = entry.value.where((device) { - if (!device.status && - !settings.kListOfflineDevices.value) { - return false; - } - return true; - }).toList(); - return ServerEntry( - server: server, - devices: devices, - searchQuery: searchQuery, - isSidebarHovering: isSidebarHovering, - ); - }), + for (var MapEntry(key: server, value: devices) + in _serversEntries) + () { + devices = devices.where((device) { + if (!device.status && + !settings.kListOfflineDevices.value) { + return false; + } + return true; + }).toList(); + return ServerEntry( + server: server, + devices: devices, + searchQuery: searchQuery, + isSidebarHovering: isSidebarHovering, + ); + }(), ]), ), ), @@ -244,23 +248,29 @@ class ServerEntry extends StatelessWidget { } else if (isSidebarHovering && devices.isNotEmpty) { return SquaredIconButton( icon: Icon( - isAllInView ? Icons.playlist_remove : Icons.playlist_add, + view.isLayoutLocked(view.currentLayout) + ? Icons.lock + : isAllInView + ? Icons.playlist_remove + : Icons.playlist_add, ), tooltip: isAllInView ? loc.removeAllFromView : loc.addAllToView, - onPressed: () { - if (isAllInView) { - view.removeDevicesFromCurrentLayout( - devices, - ); - } else { - for (final device in devices) { - if (device.status && - !view.currentLayout.devices.contains(device)) { - view.add(device); - } - } - } - }, + onPressed: view.isLayoutLocked(view.currentLayout) + ? null + : () { + if (isAllInView) { + view.removeDevicesFromCurrentLayout( + devices, + ); + } else { + for (final device in devices) { + if (device.status && + !view.currentLayout.devices.contains(device)) { + view.add(device); + } + } + } + }, ); } else { return const SizedBox.shrink(); diff --git a/lib/screens/servers/configure_dvr_server.dart b/lib/screens/servers/configure_dvr_server.dart index a30c2f93..aedb33c2 100644 --- a/lib/screens/servers/configure_dvr_server.dart +++ b/lib/screens/servers/configure_dvr_server.dart @@ -426,13 +426,10 @@ class _ConfigureDVRServerScreenState extends State { final name = nameController.text.trim(); final hostname = getServerHostname(hostnameController.text.trim()); + final port = int.parse(portController.text.trim()); if (ServersProvider.instance.servers.any((s) { - final serverHost = Uri.parse(s.login).host; - final newServerHost = Uri.parse(hostname).host; - return serverHost.isNotEmpty && - newServerHost.isNotEmpty && - serverHost == newServerHost; + return s.ip == hostname && s.port == port; })) { showDialog( context: context, @@ -454,7 +451,6 @@ class _ConfigureDVRServerScreenState extends State { state = _ServerAddState.checkingServerCredentials; }); } - final port = int.parse(portController.text.trim()); final (code, server) = await API.instance.checkServerCredentials( Server( name: name, diff --git a/lib/utils/app_links/app_links.dart b/lib/utils/app_links/app_links.dart index a79d4841..6bb7eb99 100644 --- a/lib/utils/app_links/app_links.dart +++ b/lib/utils/app_links/app_links.dart @@ -220,6 +220,7 @@ Future handleArgs( if (serverResult == null) { throw ArgumentError('Server $server not found'); } else { + await ServersProvider.instance.refreshDevices(); final deviceResult = serverResult.devices.firstWhereOrNull((d) { return d.id.toString() == camera; }); diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index 6bac8bc7..13e2deb3 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -173,11 +173,14 @@ extension IterableTExtension on Iterable { /// /// This is useful when you have a list of elements and you want to know which /// element appears the most. - T findMaxDuplicatedElementInList() => fold>( - {}, - (map, element) => - map..update(element, (value) => value + 1, ifAbsent: () => 1)) - .entries - .reduce((e1, e2) => e1.value > e2.value ? e1 : e2) - .key; + T findMaxDuplicatedElementInList() { + if (length == 1) return first; + return fold>( + {}, + (map, element) => + map..update(element, (value) => value + 1, ifAbsent: () => 1)) + .entries + .reduce((e1, e2) => e1.value > e2.value ? e1 : e2) + .key; + } } diff --git a/lib/utils/window.dart b/lib/utils/window.dart index fe66c760..e96b8bd0 100644 --- a/lib/utils/window.dart +++ b/lib/utils/window.dart @@ -33,6 +33,11 @@ import 'package:provider/provider.dart'; import 'package:unity_multi_window/unity_multi_window.dart'; import 'package:window_manager/window_manager.dart'; +/// The small window size. +/// +/// This is used in debug mode and when the window is a sub window. +const kSmallWindowSize = Size(300, 200); + /// The initial size of the window const kInitialWindowSize = Size(1066, 645); @@ -56,7 +61,11 @@ Future configureWindow({bool? fullscreen}) async { await WindowManager.instance.ensureInitialized(); await windowManager.waitUntilReadyToShow( WindowOptions( - minimumSize: kDebugMode ? Size(100, 100) : kInitialWindowSize, + minimumSize: () { + if (kDebugMode) return kSmallWindowSize; + if (isSubWindow) return kSmallWindowSize; + return kInitialWindowSize; + }(), skipTaskbar: false, titleBarStyle: TitleBarStyle.hidden, windowButtonVisibility: true, @@ -125,7 +134,7 @@ extension DeviceWindowExtension on Device { assert(!isEmbedded, 'Can not open a new window in an embedded environment'); - debugPrint('Opening a new window'); + debugPrint('Opening a new window for device $name (${server.name}#$id)'); final window = await MultiWindow.run([ '--theme', SettingsProvider.instance.kThemeMode.value.name, diff --git a/lib/widgets/reorderable_static_grid.dart b/lib/widgets/reorderable_static_grid.dart index 754f90f9..3e2b93e7 100644 --- a/lib/widgets/reorderable_static_grid.dart +++ b/lib/widgets/reorderable_static_grid.dart @@ -22,7 +22,7 @@ import 'package:flutter/material.dart'; import 'package:reorderables/reorderables.dart'; /// The default spacing between the grid items -const kGridInnerPadding = 8.0; +const kGridInnerPadding = 4.0; /// The default padding for the grid const kGridPadding = EdgeInsetsDirectional.all(10.0); @@ -69,7 +69,7 @@ class StaticGrid extends StatefulWidget { class StaticGridState extends State { List realChildren = []; - int get gridRows => (realChildren.length / widget.crossAxisCount).round(); + int get gridRows => (realChildren.length / widget.crossAxisCount).ceil(); void generateRealChildren() { realChildren = [...widget.children]; @@ -108,53 +108,75 @@ class StaticGridState extends State { if (widget.children.isEmpty && widget.emptyChild != null) { return widget.emptyChild!; } - return Padding( - padding: widget.padding.add(EdgeInsetsDirectional.only( - start: widget.crossAxisSpacing, - top: widget.mainAxisSpacing, + padding: widget.padding.add(EdgeInsetsDirectional.symmetric( + horizontal: widget.crossAxisSpacing, + vertical: widget.mainAxisSpacing, )), child: LayoutBuilder(builder: (context, constraints) { - var width = (constraints.biggest.width / widget.crossAxisCount) - - widget.mainAxisSpacing; - - final height = width / widget.childAspectRatio; - final gridHeight = - height * gridRows + widget.crossAxisSpacing * gridRows; - - // If the items heights summed will overflow the available space, reduce - // the width of the items, making it possible to fit all the items in the - // view - if (gridHeight > constraints.biggest.height) { - width -= gridHeight - constraints.biggest.height; + final availableWidth = constraints.biggest.width - + widget.padding.horizontal - + (widget.crossAxisCount - 1) * widget.crossAxisSpacing; + + var childWidth = availableWidth / widget.crossAxisCount; + final childHeight = childWidth / widget.childAspectRatio; + double gridHeight = + childHeight * gridRows + widget.crossAxisSpacing * (gridRows - 1); + + if (gridRows == 1) { + // For a single row, ensure childHeight does not exceed the + // available height + if (childHeight > constraints.biggest.height) { + final maxHeight = constraints.biggest.height; + childWidth = maxHeight * widget.childAspectRatio; + gridHeight = maxHeight; + } + } else { + // For multiple rows, calculate gridHeight and adjust childWidth if + // necessary + gridHeight = + childHeight * gridRows + widget.crossAxisSpacing * (gridRows - 1); + + if (gridHeight > constraints.biggest.height) { + // Calculate the maximum height each child can have to fit + // within the available height + final maxHeight = + constraints.biggest.height / gridRows - widget.crossAxisSpacing; + + // Calculate the new width based on the maximum height and + // the aspect ratio + childWidth = maxHeight * widget.childAspectRatio; + } } - return ScrollConfiguration( - behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), - child: ReorderableWrap( - enableReorder: widget.reorderable, - spacing: widget.mainAxisSpacing, - runSpacing: widget.crossAxisSpacing, - minMainAxisCount: widget.crossAxisCount, - maxMainAxisCount: widget.crossAxisCount, - onReorder: widget.onReorder, - needsLongPressDraggable: isMobile, - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - scrollPhysics: const NeverScrollableScrollPhysics(), - children: List.generate(realChildren.length, (index) { - return SizedBox( - key: ValueKey(index), - width: width, - child: Center( + return SizedBox( + height: gridHeight, + child: ScrollConfiguration( + behavior: + ScrollConfiguration.of(context).copyWith(scrollbars: true), + child: ReorderableWrap( + enableReorder: widget.reorderable, + spacing: widget.mainAxisSpacing, + runSpacing: widget.crossAxisSpacing, + minMainAxisCount: widget.crossAxisCount, + maxMainAxisCount: widget.crossAxisCount, + onReorder: widget.onReorder, + needsLongPressDraggable: isMobile, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + scrollPhysics: const NeverScrollableScrollPhysics(), + children: List.generate(realChildren.length, (index) { + return SizedBox( + key: ValueKey(index), + width: childWidth, child: AspectRatio( aspectRatio: widget.childAspectRatio, child: realChildren[index], ), - ), - ); - }), + ); + }), + ), ), ); }), 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 f6aca31f..5bcf7f89 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 @@ -239,9 +239,11 @@ abstract class UnityVideoPlayer with ChangeNotifier { /// Implementations must call this when the video source is ready to be /// listened to. late final VoidCallback onReady; + bool _isReady = false; UnityVideoPlayer({this.width, this.height}) { onReady = () { + _isReady = true; _onErrorSubscription = onError.listen(_onError); _onDurationUpdateSubscription = onDurationUpdate.listen(_onDurationUpdate); @@ -446,14 +448,16 @@ abstract class UnityVideoPlayer with ChangeNotifier { @mustCallSuper @override Future dispose() async { - try { - _onDurationUpdateSubscription.cancel(); - _onErrorSubscription.cancel(); - _onPositionUpdateSubscription.cancel(); - _fpsSubscription.cancel(); - _oldImageTimer?.cancel(); - } catch (error, stack) { - debugPrint('Tried to cancel subscriptions but failed: $error, $stack'); + if (_isReady) { + try { + _onDurationUpdateSubscription.cancel(); + _onErrorSubscription.cancel(); + _onPositionUpdateSubscription.cancel(); + _fpsSubscription.cancel(); + _oldImageTimer?.cancel(); + } catch (error, stack) { + debugPrint('Tried to cancel subscriptions but failed: $error, $stack'); + } } _lastImageTime = null; _isImageOld = false;