diff --git a/lib/api/api.dart b/lib/api/api.dart index f979193a..e3dab7c7 100644 --- a/lib/api/api.dart +++ b/lib/api/api.dart @@ -176,33 +176,36 @@ class API { // debugPrint(response.body); final parser = Xml2Json()..parse(response.body); - final events = jsonDecode(parser.toGData())['feed']['entry'].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 - ? DateTime.now() - : DateTime.parse(e['published']['\$t']), - e['updated'] == null || e['updated']['\$t'] == null - ? DateTime.now() - : DateTime.parse(e['updated']['\$t']), - e['category']['term'], - !e.containsKey('content') - ? null - : int.parse(e['content']['media_id']), - !e.containsKey('content') - ? null - : Uri.parse( - e['content'][r'$t'].replaceAll( - 'https://', - 'https://${Uri.encodeComponent(server.login)}:${Uri.encodeComponent(server.password)}@', - ), - ), - ); - }).cast(); + final events = (jsonDecode(parser.toGData())['feed']['entry'] as List) + .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 + ? DateTime.now() + : DateTime.parse(e['published']['\$t']).toLocal(), + e['updated'] == null || e['updated']['\$t'] == null + ? DateTime.now() + : DateTime.parse(e['updated']['\$t']).toLocal(), + e['category']['term'], + !e.containsKey('content') + ? null + : int.parse(e['content']['media_id']), + !e.containsKey('content') + ? null + : Uri.parse( + e['content'][r'$t'].replaceAll( + 'https://', + 'https://${Uri.encodeComponent(server.login)}:${Uri.encodeComponent(server.password)}@', + ), + ), + ); + }) + .where((e) => e.duration > const Duration(minutes: 1)) + .cast(); debugPrint('Loaded ${events.length} events for server ${server.name}'); return events; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a2e73cb5..9462c0e6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -46,6 +46,7 @@ "replaceCamera": "Replace Camera", "reloadCamera": "Reload Camera", "selectACamera": "Select a camera", + "switchCamera": "Switch camera", "online": "Online", "offline": "Offline", "removeFromView": "Remove from view", @@ -56,6 +57,8 @@ "event": "Event", "duration": "Duration", "priority": "Priority", + "next": "Next", + "previous": "Previous", "date": "Date", "lastUpdate": "Last Update", "theme": "Theme", @@ -146,6 +149,7 @@ "downloads": "Downloads", "download": "Download", "downloaded": "Downloaded", + "downloading": "Downloading", "seeInDownloads": "See in Downloads", "delete": "Delete", "showInFiles": "Show in Files", @@ -181,6 +185,12 @@ "toDate": "To", "allowAlarms": "Allow alarms", "nextEvents": "Next events", + "nEvents": "{n} events", + "@nEvents": { + "placeholders": { + "n": {} + } + }, "@Event Priorities": {}, "info": "Info", "warn": "Warning", diff --git a/lib/main.dart b/lib/main.dart index 7801ad47..23f267f3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -223,7 +223,9 @@ class UnityApp extends StatelessWidget { if (settings.name == '/events') { final data = settings.arguments! as Map; final event = data['event'] as Event; - final upcomingEvents = data['upcoming'] as Iterable; + final upcomingEvents = + (data['upcoming'] as Iterable?) ?? []; + final videoPlayer = data['videoPlayer'] as UnityVideoPlayer?; return MaterialPageRoute( settings: RouteSettings( @@ -234,6 +236,7 @@ class UnityApp extends StatelessWidget { return EventPlayerScreen( event: event, upcomingEvents: upcomingEvents, + player: videoPlayer, ); }, ); diff --git a/lib/models/device.dart b/lib/models/device.dart index cce289db..4778ed19 100644 --- a/lib/models/device.dart +++ b/lib/models/device.dart @@ -53,6 +53,15 @@ class Device { this.hasPTZ = false, }); + Device.dump({ + this.name = 'device', + this.id = 0, + this.status = true, + this.resolutionX = 640, + this.resolutionY = 480, + this.hasPTZ = false, + }) : server = Server.dump(); + String get uri => 'live/$id'; factory Device.fromServerJson(Map map, Server server) { diff --git a/lib/models/event.dart b/lib/models/event.dart index 258d53f9..b4f957c7 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -19,6 +19,7 @@ import 'package:bluecherry_client/models/server.dart'; import 'package:bluecherry_client/providers/server_provider.dart'; +import 'package:bluecherry_client/utils/extensions.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -66,14 +67,12 @@ class Event { .last .trim() .split(' ') - .map((e) => e.isEmpty ? '' : e[0].toUpperCase() + e.substring(1)) + .map((e) => e.uppercaseFirst()) .join(' '); } Duration get duration { - // return mediaDuration ?? updated.difference(published); - // TODO(bdlukaa): for some reason, the diff is off by a few seconds. use this to counterpart the issue - final dur = updated.difference(published) - const Duration(seconds: 3); + final dur = updated.difference(published); if (dur < Duration.zero) return updated.difference(published); return dur; } diff --git a/lib/models/server.dart b/lib/models/server.dart index 5326a1ec..3b2424b4 100644 --- a/lib/models/server.dart +++ b/lib/models/server.dart @@ -61,6 +61,22 @@ class Server { this.passedCertificates = true, }); + Server.dump({ + this.name = 'server', + this.ip = 'server:ip', + this.port = 7001, + this.login = 'admin', + this.password = 'admin', + this.devices = const [], + this.rtspPort = 7002, + this.serverUUID, + this.cookie, + this.savePassword = false, + this.connectAutomaticallyAtStartup = true, + this.online = true, + this.passedCertificates = true, + }); + String get id { return '$name;$ip;$port'; } diff --git a/lib/providers/home_provider.dart b/lib/providers/home_provider.dart index fe6d361f..2e916017 100644 --- a/lib/providers/home_provider.dart +++ b/lib/providers/home_provider.dart @@ -109,19 +109,20 @@ class HomeProvider extends ChangeNotifier { final home = context.read(); final tab = home.tab; - /// On device grid or in eventsPlayback, use landscape - if ([ - UnityTab.deviceGrid.index, - UnityTab.eventsPlayback.index, - ].contains(tab)) { + /// On device grid, use landscape + if ([UnityTab.deviceGrid.index].contains(tab)) { setDefaultStatusBarStyle(); DeviceOrientations.instance.set([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]); + } else if ([UnityTab.directCameraScreen.index].contains(tab)) { + setDefaultStatusBarStyle(); + DeviceOrientations.instance.set([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); } else if ([UnityTab.addServer.index].contains(tab)) { - // Use portrait orientation in "Add Server" tab. - // See #14. setDefaultStatusBarStyle(isLight: true); DeviceOrientations.instance.set([ DeviceOrientation.portraitUp, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 91caa496..522564fd 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -228,7 +228,7 @@ class SettingsProvider extends ChangeNotifier { /// Formats the date according to the current [dateFormat]. /// /// [toLocal] defines if the date will be converted to local time. Defaults to `true` - String formatDate(DateTime date, {bool toLocal = true}) { + String formatDate(DateTime date, {bool toLocal = false}) { if (toLocal) date = date.toLocal(); return dateFormat.format(date); @@ -237,7 +237,7 @@ class SettingsProvider extends ChangeNotifier { /// Formats the date according to the current [dateFormat]. /// /// [toLocal] defines if the date will be converted to local time. Defaults to `true` - String formatTime(DateTime time, {bool toLocal = true}) { + String formatTime(DateTime time, {bool toLocal = false}) { if (toLocal) time = time.toLocal(); return timeFormat.format(time); diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index ef3045dc..d548377d 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -75,6 +75,20 @@ extension DurationExtension on Duration { return this; } + + double get inDoubleSeconds { + return inMilliseconds / 1000; + } +} + +extension IterableExtension on Iterable { + T? firstWhereOrNull(bool Function(T element) test) { + try { + return firstWhere(test); + } catch (_) { + return null; + } + } } extension NotificationExtensions on NotificationClickAction { @@ -152,9 +166,9 @@ extension DateTimeExtension on DateTime { } bool isInBetween(DateTime first, DateTime second) { - return isAfter(first) && isBefore(second) || - this == first || - this == second; + return (isAfter(first) && isBefore(second)) || + isAtSameMomentAs(first) || + isAtSameMomentAs(second); } } diff --git a/lib/utils/tree_view/tree_view.dart b/lib/utils/tree_view/tree_view.dart index 1f99dca4..863d501c 100644 --- a/lib/utils/tree_view/tree_view.dart +++ b/lib/utils/tree_view/tree_view.dart @@ -7,6 +7,29 @@ import 'package:flutter_simple_treeview/flutter_simple_treeview.dart' export 'package:flutter_simple_treeview/flutter_simple_treeview.dart' show TreeNode, TreeController; +Widget buildCheckbox({ + required bool? value, + required ValueChanged onChanged, + required bool isError, + double checkboxScale = 0.8, +}) { + return Transform.scale( + scale: checkboxScale, + child: Checkbox( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: const VisualDensity( + horizontal: -4, + vertical: -4, + ), + splashRadius: 0.0, + tristate: true, + value: value, + isError: isError, + onChanged: onChanged, + ), + ); +} + /// Tree view with collapsible and expandable nodes. class TreeView extends StatefulWidget { /// List of root level tree nodes. diff --git a/lib/widgets/desktop_buttons.dart b/lib/widgets/desktop_buttons.dart index 1cced302..7530fc50 100644 --- a/lib/widgets/desktop_buttons.dart +++ b/lib/widgets/desktop_buttons.dart @@ -24,6 +24,7 @@ import 'package:bluecherry_client/models/device.dart'; import 'package:bluecherry_client/models/event.dart'; import 'package:bluecherry_client/providers/home_provider.dart'; import 'package:bluecherry_client/widgets/events/events_screen.dart'; +import 'package:bluecherry_client/widgets/events_timeline/events_playback.dart'; import 'package:bluecherry_client/widgets/home.dart'; import 'package:bluecherry_client/widgets/misc.dart'; import 'package:flutter/material.dart'; @@ -200,10 +201,12 @@ class _WindowButtonsState extends State with WindowListener { padding: EdgeInsets.symmetric(horizontal: 8.0), child: UnityLoadingIndicator(), ) - else if (home.tab == UnityTab.eventsScreen.index && !canPop) + else if (home.tab == UnityTab.eventsScreen.index || + home.tab == UnityTab.eventsPlayback.index && !canPop) IconButton( onPressed: () { eventsScreenKey.currentState?.fetch(); + eventsPlaybackScreenKey.currentState?.fetch(); }, icon: const Icon(Icons.refresh), iconSize: 20.0, diff --git a/lib/widgets/device_grid/desktop/desktop_device_grid.dart b/lib/widgets/device_grid/desktop/desktop_device_grid.dart index 75f9765f..11fd7e7c 100644 --- a/lib/widgets/device_grid/desktop/desktop_device_grid.dart +++ b/lib/widgets/device_grid/desktop/desktop_device_grid.dart @@ -257,6 +257,7 @@ class DesktopDeviceTile extends StatelessWidget { return UnityVideoView( key: ValueKey(device.fullName), + heroTag: device.streamURL, player: videoPlayer, paneBuilder: (context, controller) { return DesktopTileViewport(controller: controller, device: device); @@ -322,6 +323,25 @@ class DesktopTileViewport extends StatefulWidget { State createState() => _DesktopTileViewportState(); } +const shadows = [ + Shadow( + blurRadius: 10, + offset: Offset(-4, -4), + ), + Shadow( + blurRadius: 10, + offset: Offset(4, 4), + ), + Shadow( + blurRadius: 10, + offset: Offset(-4, 4), + ), + Shadow( + blurRadius: 10, + offset: Offset(4, -4), + ), +]; + class _DesktopTileViewportState extends State { bool ptzEnabled = false; @@ -359,7 +379,6 @@ class _DesktopTileViewportState extends State { final theme = Theme.of(context); final view = context.watch(); - final isSubView = AlternativeWindow.maybeOf(context) != null; Widget foreground = PTZController( diff --git a/lib/widgets/device_grid/desktop/layout_manager.dart b/lib/widgets/device_grid/desktop/layout_manager.dart index 6784dc7c..3138ac21 100644 --- a/lib/widgets/device_grid/desktop/layout_manager.dart +++ b/lib/widgets/device_grid/desktop/layout_manager.dart @@ -51,7 +51,8 @@ class _LayoutManagerState extends State { final settings = context.watch(); timer?.cancel(); timer = Timer.periodic(settings.layoutCyclingTogglePeriod, (timer) { - final settings = SettingsProvider.instance; + if (!mounted) return; + final view = DesktopViewProvider.instance; if (settings.layoutCyclingEnabled) { diff --git a/lib/widgets/device_grid/mobile/device_view.dart b/lib/widgets/device_grid/mobile/device_view.dart index 45178e75..f0775e40 100644 --- a/lib/widgets/device_grid/mobile/device_view.dart +++ b/lib/widgets/device_grid/mobile/device_view.dart @@ -149,13 +149,8 @@ class _MobileDeviceViewState extends State { break; case 1: if (mounted) { - final result = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const DeviceSelectorScreen(), - ), - ); - - if (result is Device) { + final result = await showDeviceSelectorScreen(context); + if (result != null) { view.replace(widget.tab, widget.index, result); if (mounted) setState(() {}); } @@ -251,6 +246,7 @@ class DeviceTileState extends State { if (videoPlayer == null) return const SizedBox.shrink(); return UnityVideoView( + heroTag: widget.device.streamURL, player: videoPlayer!, paneBuilder: (context, controller) { final error = UnityVideoView.of(context).error; diff --git a/lib/widgets/device_selector_screen.dart b/lib/widgets/device_selector_screen.dart index cc923b30..7ea371d6 100644 --- a/lib/widgets/device_selector_screen.dart +++ b/lib/widgets/device_selector_screen.dart @@ -26,14 +26,54 @@ import 'package:bluecherry_client/widgets/misc.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:provider/provider.dart'; +import 'package:sliver_tools/sliver_tools.dart'; + +typedef EventsPerDevice = Map; + +Future showDeviceSelectorScreen( + BuildContext context, { + List selected = const [], + Iterable? available, + EventsPerDevice eventsPerDevice = const {}, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + clipBehavior: Clip.hardEdge, + builder: (context) { + return DraggableScrollableSheet( + maxChildSize: 0.85, + initialChildSize: 0.7, + expand: false, + builder: (context, controller) { + return PrimaryScrollController( + controller: controller, + child: DeviceSelectorScreen( + selected: selected, + available: available, + eventsPerDevice: eventsPerDevice, + ), + ); + }, + ); + }, + ); +} class DeviceSelectorScreen extends StatelessWidget { /// The devices already selected final Iterable selected; + final Iterable? available; + + /// The amount of events per device + final EventsPerDevice eventsPerDevice; + const DeviceSelectorScreen({ super.key, this.selected = const [], + this.available, + this.eventsPerDevice = const {}, }); @override @@ -44,52 +84,36 @@ class DeviceSelectorScreen extends StatelessWidget { final viewPadding = MediaQuery.viewPaddingOf(context); return Scaffold( - appBar: AppBar(title: Text(loc.selectACamera)), + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + tooltip: MaterialLocalizations.of(context).closeButtonTooltip, + onPressed: () => Navigator.of(context).maybePop(), + ), + title: Text(loc.selectACamera), + ), body: () { - if (servers.servers.isEmpty) { - return const NoServerWarning(); - } - - return ListView.builder( - itemCount: servers.servers.length, - itemBuilder: (context, index) { - final server = servers.servers[index]; - final isLoading = servers.isServerLoading(server); - - if (isLoading) { - return Center( - child: Container( - alignment: AlignmentDirectional.center, - height: 156.0, - child: const CircularProgressIndicator.adaptive(), - ), - ); - } - - final devices = server.devices.sorted(); + if (servers.servers.isEmpty) return const NoServerWarning(); - return ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: devices.length + 1, - // EdgeInsetsDirectional can not be used here because viewPadding - // is EdgeInsets. We are just avoiding the top - padding: EdgeInsets.only( - left: viewPadding.left, - right: viewPadding.right, - bottom: viewPadding.bottom, - ), - itemBuilder: (context, index) { - if (index == 0) { - return SubHeader( + return Padding( + padding: EdgeInsets.only( + left: viewPadding.left, + right: viewPadding.right, + bottom: viewPadding.bottom, + ), + child: CustomScrollView(slivers: [ + for (final server in servers.servers) + MultiSliver(pushPinnedChildren: true, children: [ + SliverPinnedHeader( + child: SubHeader( server.name, subtext: server.online - ? loc.nDevices(devices.length) + ? loc.nDevices(server.devices.length) : loc.offline, subtextStyle: TextStyle( color: !server.online ? theme.colorScheme.error : null, ), - trailing: isLoading + trailing: servers.isServerLoading(server) ? const SizedBox( height: 16.0, width: 16.0, @@ -98,40 +122,50 @@ class DeviceSelectorScreen extends StatelessWidget { ), ) : null, - ); - } - - index--; + ), + ), + SliverList.builder( + itemCount: server.devices.length, + itemBuilder: (context, index) { + final devices = server.devices.sorted(); + final device = devices[index]; - final device = devices[index]; - final isSelected = selected.contains(device); + final isSelected = selected.contains(device); + final isAvailable = available?.contains(device) ?? true; + final enabled = device.status && !isSelected && isAvailable; - return ListTile( - enabled: device.status && !isSelected, - leading: CircleAvatar( - backgroundColor: Colors.transparent, - foregroundColor: device.status - ? theme.extension()!.successColor - : theme.colorScheme.error, - child: const Icon(Icons.camera_alt), - ), - title: Text( - device.name - .split(' ') - .map((e) => e[0].toUpperCase() + e.substring(1)) - .join(' '), - ), - subtitle: Text( - [ - device.uri, - '${device.resolutionX}x${device.resolutionY}', - ].join(' • '), - ), - onTap: () => Navigator.of(context).pop(device), - ); - }, - ); - }, + return ListTile( + enabled: enabled, + leading: CircleAvatar( + backgroundColor: Colors.transparent, + foregroundColor: device.status + ? enabled + ? theme.extension()!.successColor + : theme.disabledColor + : theme.colorScheme.error, + child: const Icon(Icons.camera_alt), + ), + title: RichText( + text: TextSpan(children: [ + TextSpan(text: device.name.uppercaseFirst()), + if (eventsPerDevice[device] != null) + TextSpan( + text: + ' (${loc.nEvents(eventsPerDevice[device]!)})', + style: theme.textTheme.labelSmall, + ), + ]), + ), + subtitle: Text([ + device.uri, + '${device.resolutionX}x${device.resolutionY}', + ].join(' • ')), + onTap: () => Navigator.of(context).pop(device), + ); + }, + ), + ]), + ]), ); }(), ); diff --git a/lib/widgets/events/event_player_desktop.dart b/lib/widgets/events/event_player_desktop.dart index 2ec055d3..123c07b8 100644 --- a/lib/widgets/events/event_player_desktop.dart +++ b/lib/widgets/events/event_player_desktop.dart @@ -42,11 +42,13 @@ class EventPlayerDesktop extends StatefulWidget { final Event event; final Iterable upcomingEvents; + final UnityVideoPlayer? player; const EventPlayerDesktop({ super.key, required this.event, this.upcomingEvents = const [], + this.player, }); @override @@ -58,9 +60,11 @@ class _EventPlayerDesktopState extends State late Event currentEvent; final focusNode = FocusNode(); - final videoController = UnityVideoPlayer.create( - quality: UnityVideoQuality.p480, - ); + late final videoController = widget.player ?? + UnityVideoPlayer.create( + quality: UnityVideoQuality.p480, + enableCache: true, + ); late final StreamSubscription playingSubscription; late final playingAnimationController = AnimationController( vsync: this, @@ -165,6 +169,7 @@ class _EventPlayerDesktopState extends State Expanded( child: InteractiveViewer( child: UnityVideoView( + heroTag: currentEvent.mediaURL, player: videoController, paneBuilder: (context, controller) { final error = UnityVideoView.of(context).error; diff --git a/lib/widgets/events/event_player_mobile.dart b/lib/widgets/events/event_player_mobile.dart index 241d8495..8a33a919 100644 --- a/lib/widgets/events/event_player_mobile.dart +++ b/lib/widgets/events/event_player_mobile.dart @@ -22,34 +22,42 @@ part of 'events_screen.dart'; class EventPlayerScreen extends StatelessWidget { final Event event; final Iterable upcomingEvents; + final UnityVideoPlayer? player; const EventPlayerScreen({ super.key, required this.event, required this.upcomingEvents, + this.player, }); @override Widget build(BuildContext context) { - if (isDesktop) { - return EventPlayerDesktop(event: event, upcomingEvents: upcomingEvents); - } else { - return _EventPlayerMobile(event: event); - } + return LayoutBuilder(builder: (context, constraints) { + if (isMobile || constraints.maxWidth < kMobileBreakpoint.width) { + return _EventPlayerMobile(event: event, player: player); + } + return EventPlayerDesktop( + event: event, + upcomingEvents: upcomingEvents, + player: player, + ); + }); } } class _EventPlayerMobile extends StatefulWidget { final Event event; + final UnityVideoPlayer? player; - const _EventPlayerMobile({required this.event}); + const _EventPlayerMobile({required this.event, this.player}); @override State<_EventPlayerMobile> createState() => __EventPlayerMobileState(); } class __EventPlayerMobileState extends State<_EventPlayerMobile> { - final videoController = UnityVideoPlayer.create(); + late final videoController = widget.player ?? UnityVideoPlayer.create(); @override void initState() { @@ -81,9 +89,11 @@ class __EventPlayerMobileState extends State<_EventPlayerMobile> { @override void dispose() { - videoController - ..release() - ..dispose(); + if (widget.player == null) { + videoController + ..release() + ..dispose(); + } DeviceOrientations.instance.set(DeviceOrientation.values); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); @@ -99,6 +109,7 @@ class __EventPlayerMobileState extends State<_EventPlayerMobile> { Expanded( child: SafeArea( child: UnityVideoView( + heroTag: widget.event.mediaURL, player: videoController, videoBuilder: (context, video) { return InteractiveViewer( @@ -263,15 +274,9 @@ class _VideoViewportState extends State { ); } else { return GestureDetector( - child: Icon( - player.isPlaying ? Icons.pause : Icons.play_arrow, + child: PlayPauseIcon( + isPlaying: player.isPlaying, color: Colors.white, - shadows: const [ - BoxShadow( - color: Colors.black54, - blurRadius: 15.0, - offset: Offset(0.0, 0.75)), - ], size: 56.0, ), onTap: () { diff --git a/lib/widgets/events/events_screen.dart b/lib/widgets/events/events_screen.dart index 61597d71..b071ad28 100644 --- a/lib/widgets/events/events_screen.dart +++ b/lib/widgets/events/events_screen.dart @@ -198,13 +198,13 @@ class _EventsScreenState extends State { @override Widget build(BuildContext context) { - final hasDrawer = Scaffold.hasDrawer(context); - final loc = AppLocalizations.of(context); - if (ServersProvider.instance.servers.isEmpty) { return const NoServerWarning(); } + final hasDrawer = Scaffold.hasDrawer(context); + final loc = AppLocalizations.of(context); + return LayoutBuilder(builder: (context, consts) { if (hasDrawer || consts.maxWidth < kMobileBreakpoint.width) { return Column(children: [ @@ -324,29 +324,6 @@ class _EventsScreenState extends State { final theme = Theme.of(context); final servers = context.watch(); - Widget buildCheckbox({ - required Server server, - required bool? value, - required ValueChanged onChanged, - required bool isError, - }) { - return Transform.scale( - scale: checkboxScale, - child: Checkbox( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: const VisualDensity( - horizontal: -4, - vertical: -4, - ), - splashRadius: 0.0, - tristate: true, - value: value, - isError: isError, - onChanged: onChanged, - ), - ); - } - return TreeView( indent: 56, iconSize: 18.0, @@ -359,7 +336,6 @@ class _EventsScreenState extends State { return TreeNode( content: Row(children: [ buildCheckbox( - server: server, value: !allowedServers.contains(server) || isOffline ? false : isTriState @@ -378,6 +354,7 @@ class _EventsScreenState extends State { } }); }, + checkboxScale: checkboxScale, ), SizedBox(width: gapCheckboxText), Expanded( @@ -409,7 +386,6 @@ class _EventsScreenState extends State { IgnorePointer( ignoring: !device.status, child: buildCheckbox( - server: server, value: device.status ? enabled : false, isError: !device.status, onChanged: (v) { @@ -423,6 +399,7 @@ class _EventsScreenState extends State { } }); }, + checkboxScale: checkboxScale, ), ), SizedBox(width: gapCheckboxText), diff --git a/lib/widgets/events_playback/events_playback.dart b/lib/widgets/events_playback/events_playback.dart deleted file mode 100644 index b9319903..00000000 --- a/lib/widgets/events_playback/events_playback.dart +++ /dev/null @@ -1,392 +0,0 @@ -/* - * This file is a part of Bluecherry Client (https://github.com/bluecherrydvr/unity). - * - * Copyright 2022 Bluecherry, LLC - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License as - * published by the Free Software Foundation; either version 3 of - * the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -import 'package:bluecherry_client/api/api.dart'; -import 'package:bluecherry_client/models/event.dart'; -import 'package:bluecherry_client/providers/events_playback_provider.dart'; -import 'package:bluecherry_client/providers/home_provider.dart'; -import 'package:bluecherry_client/providers/server_provider.dart'; -import 'package:bluecherry_client/utils/constants.dart'; -import 'package:bluecherry_client/utils/extensions.dart'; -import 'package:bluecherry_client/widgets/error_warning.dart'; -import 'package:bluecherry_client/widgets/events_playback/events_playback_desktop.dart'; -import 'package:bluecherry_client/widgets/events_playback/events_playback_mobile.dart'; -import 'package:bluecherry_client/widgets/events_playback/timeline_controller.dart'; -import 'package:bluecherry_client/widgets/misc.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; - -class FilterData { - final List? devices; - - final DateTime fromLimit; - final DateTime toLimit; - - final DateTime from; - final DateTime to; - - final bool allowAlarms; - - const FilterData({ - required this.devices, - required this.fromLimit, - required this.toLimit, - required this.from, - required this.to, - required this.allowAlarms, - }); - - factory FilterData.standard({ - required List? devices, - required DateTime fromLimit, - required DateTime toLimit, - }) { - final now = DateTime.now(); - final desiredFrom = now.subtract(const Duration(days: 1)); - - return FilterData( - allowAlarms: false, - from: desiredFrom.isBefore(fromLimit) ? fromLimit : desiredFrom, - to: now, - fromLimit: fromLimit, - toLimit: toLimit, - devices: devices, - ); - } - - FilterData copyWith({ - List? devices, - DateTime? fromLimit, - DateTime? toLimit, - DateTime? from, - DateTime? to, - bool? allowAlarms, - }) { - return FilterData( - devices: devices ?? this.devices, - fromLimit: fromLimit ?? this.fromLimit, - toLimit: toLimit ?? this.toLimit, - from: from ?? this.from, - to: to ?? this.to, - allowAlarms: allowAlarms ?? this.allowAlarms, - ); - } - - @override - String toString() { - return 'FilterData(devices: $devices, fromLimit: $fromLimit, toLimit: $toLimit, from: $from, to: $to, allowAlarms: $allowAlarms)'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is FilterData && - listEquals(other.devices, devices) && - other.fromLimit == fromLimit && - other.toLimit == toLimit && - other.from == from && - other.to == to && - other.allowAlarms == allowAlarms; - } - - @override - int get hashCode { - return devices.hashCode ^ - fromLimit.hashCode ^ - toLimit.hashCode ^ - from.hashCode ^ - to.hashCode ^ - allowAlarms.hashCode; - } -} - -// Device Id : Events for device -typedef EventsData = Map>; - -class EventsPlayback extends StatefulWidget { - const EventsPlayback({super.key}); - - @override - State createState() => _EventsPlaybackState(); -} - -class _EventsPlaybackState extends State { - bool isFirstTimeLoading = true; - EventsData eventsForDevice = {}; - - FilterData? filterData; - EventsData filteredData = {}; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => fetch()); - } - - Future fetch() async { - final home = context.read() - ..loading(UnityLoadingReason.fetchingEventsPlayback); - - try { - await Future.wait(ServersProvider.instance.servers.map((server) async { - for (final server in ServersProvider.instance.servers) { - if (!server.online) continue; - - try { - final events = await API.instance.getEvents( - await API.instance.checkServerCredentials(server), - ); - - for (final event in events) { - if (!server.devices.any((d) => d.name == event.deviceName) || - event.duration == Duration.zero) { - continue; - } - - final device = - server.devices.firstWhere((d) => d.name == event.deviceName); - final id = EventsProvider.idForDevice(device); - - if (eventsForDevice.containsKey(id)) { - eventsForDevice[id]!.add(event); - } else { - eventsForDevice[id] = [event]; - } - } - } catch (exception, stacktrace) { - debugPrint(exception.toString()); - debugPrint(stacktrace.toString()); - } - } - })); - } catch (exception, stacktrace) { - debugPrint(exception.toString()); - debugPrint(stacktrace.toString()); - } - - if (eventsForDevice.isNotEmpty) { - final allEvents = eventsForDevice.values.reduce((a, b) => a + b); - final from = allEvents.oldest; - final to = allEvents.newest; - filterData = FilterData.standard( - devices: null, - fromLimit: from.published, - toLimit: to.published, - ); - - await updateFilteredData(); - } - - home.notLoading(UnityLoadingReason.fetchingEventsPlayback); - - if (mounted) { - setState(() { - isFirstTimeLoading = false; - }); - } - } - - Future updateFilteredData() async { - filteredData = await compute(_filterData, [ - eventsForDevice, - filterData, - ]); - } - - static EventsData _filterData(List data) { - final eventsForDevice = data[0] as EventsData; - final filterData = data[1] as FilterData?; - - return Map.fromEntries( - eventsForDevice.entries.where((entry) { - if (filterData == null) return true; - - if (filterData.devices != null && - !filterData.devices!.contains(entry.key)) { - return false; - } - - return true; - }).map((e) { - if (filterData == null) return e; - - final events = e.value.where((event) { - final passDate = filterData.from.isBefore(event.published) && - filterData.to.isAfter(event.published); - - final passAlarm = filterData.allowAlarms ? true : !event.isAlarm; - - return passDate && passAlarm; - }).toList(); - - return MapEntry(e.key, events); - }).where((e) => e.value.isNotEmpty), - ); - } - - @override - Widget build(BuildContext context) { - final servers = context.watch(); - if (servers.servers.isEmpty) { - return const Stack(alignment: Alignment.center, children: [ - PositionedDirectional( - top: 0, - start: 0, - child: SafeArea(child: UnityDrawerButton()), - ), - NoServerWarning(), - ]); - } - - final home = context.watch(); - - Future onFilter(FilterData filter) async { - home.loading(UnityLoadingReason.fetchingEventsPlayback); - - filterData = filter; - await updateFilteredData(); - setState(() {}); - - home.notLoading(UnityLoadingReason.fetchingEventsPlayback); - } - - final hasDrawer = Scaffold.hasDrawer(context); - - return LayoutBuilder(builder: (context, constraints) { - if (hasDrawer || constraints.maxWidth < kMobileBreakpoint.width) { - return EventsPlaybackMobile( - events: filteredData, - filter: filterData, - onFilter: onFilter, - ); - } else { - return EventsPlaybackDesktop( - events: filteredData, - filter: filterData, - onFilter: onFilter, - ); - } - }); - } -} - -abstract class EventsPlaybackWidget extends StatefulWidget { - final EventsData events; - final FilterData? filter; - final FutureValueChanged onFilter; - - const EventsPlaybackWidget({ - super.key, - required this.events, - required this.filter, - required this.onFilter, - }); -} - -abstract class EventsPlaybackState extends State { - late final timelineController = TimelineController(); - final focusNode = FocusNode(); - - @override - void didUpdateWidget(covariant EventsPlaybackWidget oldWidget) { - super.didUpdateWidget(oldWidget); - - if (oldWidget.filter != widget.filter) { - initialize(); - } - } - - void initialize() { - final selectedIds = context.read().selectedIds; - - final realEvents = ({...widget.events} - ..removeWhere((key, value) => !selectedIds.contains(key))); - - final allEvents = realEvents.isEmpty - ? [] - : realEvents.values.reduce((value, element) => value + element); - - timelineController.initialize(context, realEvents, allEvents); - } - - @override - void dispose() { - timelineController.dispose(); - focusNode.dispose(); - super.dispose(); - } - - Widget buildChild(BuildContext context); - - @override - Widget build(BuildContext context) { - return KeyboardListener( - focusNode: focusNode, - autofocus: true, - onKeyEvent: (event) { - if (event is KeyDownEvent) { - if (event.logicalKey == LogicalKeyboardKey.space) { - if (timelineController.isPaused) { - timelineController.play(context); - } else { - timelineController.pause(); - } - } else if (event.logicalKey == LogicalKeyboardKey.keyM) { - if (timelineController.isMuted) { - timelineController.unmute(); - } else { - timelineController.mute(); - } - } - } - }, - child: ListenableBuilder( - listenable: timelineController, - builder: (context, child) => buildChild(context), - ), - ); - } - - Future showFilter(BuildContext context) async { - final initiallyPaused = timelineController.isPaused; - timelineController.pause(); - - var localFilter = widget.filter; - await showDialog( - context: context, - builder: (context) { - return StatefulBuilder(builder: (context, setState) { - return FilterDialog( - filter: localFilter, - onFilter: (filter) async => setState(() => localFilter = filter), - ); - }); - }, - ); - if (widget.filter != localFilter && localFilter != null) { - widget.onFilter(localFilter!); - } - - // ignore: use_build_context_synchronously - if (!initiallyPaused) timelineController.play(context); - } -} diff --git a/lib/widgets/events_playback/events_playback_desktop.dart b/lib/widgets/events_playback/events_playback_desktop.dart deleted file mode 100644 index 55b02ca7..00000000 --- a/lib/widgets/events_playback/events_playback_desktop.dart +++ /dev/null @@ -1,652 +0,0 @@ -/* - * This file is a part of Bluecherry Client (https://github.com/bluecherrydvr/unity). - * - * Copyright 2022 Bluecherry, LLC - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License as - * published by the Free Software Foundation; either version 3 of - * the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:bluecherry_client/models/device.dart'; -import 'package:bluecherry_client/providers/events_playback_provider.dart'; -import 'package:bluecherry_client/providers/server_provider.dart'; -import 'package:bluecherry_client/providers/settings_provider.dart'; -import 'package:bluecherry_client/utils/extensions.dart'; -import 'package:bluecherry_client/utils/theme.dart'; -import 'package:bluecherry_client/widgets/collapsable_sidebar.dart'; -import 'package:bluecherry_client/widgets/device_grid/device_grid.dart'; -import 'package:bluecherry_client/widgets/error_warning.dart'; -import 'package:bluecherry_client/widgets/events/event_player_desktop.dart'; -import 'package:bluecherry_client/widgets/events_playback/events_playback.dart'; -import 'package:bluecherry_client/widgets/events_playback/timeline_controller.dart'; -import 'package:bluecherry_client/widgets/misc.dart'; -import 'package:bluecherry_client/widgets/reorderable_static_grid.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; -import 'package:unity_video_player/unity_video_player.dart'; - -typedef FutureValueChanged = Future Function(T data); - -class EventsPlaybackDesktop extends EventsPlaybackWidget { - const EventsPlaybackDesktop({ - super.key, - required super.events, - required super.filter, - required super.onFilter, - }); - - @override - State createState() => _EventsPlaybackDesktopState(); -} - -class _EventsPlaybackDesktopState extends EventsPlaybackState { - final sidebarKey = GlobalKey(); - - double? _volume; - double? _speed; - - @override - Widget buildChild(BuildContext context) { - final loc = AppLocalizations.of(context); - final settings = context.watch(); - final eventsProvider = context.watch(); - - final minTimelineHeight = kTimelineTileHeight * - // at least the height of 4 - timelineController.tiles.length.clamp( - 4, - double.infinity, - ); - final maxTimelineHeight = - (kTimelineTileHeight * timelineController.tiles.length) - .clamp(minTimelineHeight, double.infinity) + - 70.0; // 70 is the height of the controls bar - - final page = Column(children: [ - Expanded( - child: Row(children: [ - Expanded( - child: ColoredBox( - color: Colors.black, - child: () { - if (!timelineController.initialized) { - return const SizedBox.expand(); - } else if (timelineController.tiles.isEmpty) { - return Center( - child: Text( - loc.selectACamera, - style: const TextStyle(color: Colors.white), - ), - ); - } else { - return Center( - child: AspectRatio( - aspectRatio: 16 / 9, - child: StaticGrid( - crossAxisCount: calculateCrossAxisCount( - timelineController.tiles.length, - ), - childAspectRatio: 16 / 9, - onReorder: eventsProvider.onReorder, - children: timelineController.tiles.map((tile) { - final has = timelineController.currentItem - is! TimelineGap && - tile.events - .hasForDate(timelineController.currentDate); - - final color = createTheme(themeMode: ThemeMode.dark) - .canvasColor; - - return IndexedStack( - key: ValueKey(tile), - index: !has ? 0 : 1, - children: [ - Container( - color: color, - alignment: AlignmentDirectional.center, - padding: const EdgeInsets.all(12.0), - child: AutoSizeText( - loc.noRecords, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white), - ), - ), - - /// This ensures a faster initialization of the video view - /// providing a smoother experience. This isn't a good solution, - /// just a workaround for now - UnityVideoView( - player: tile.player, - color: color, - paneBuilder: (context, player) { - if (player.dataSource == null) { - return const ErrorWarning(message: ''); - } else { - // debugPrint('${player.dataSource}'); - } - - return const SizedBox.shrink(); - }, - ), - ], - ); - }).toList(), - ), - ), - ); - } - }(), - ), - ), - CollapsableSidebar( - left: false, - onCollapseStateChange: (v) { - setState(() {}); - }, - builder: (context, collapseButton) { - return Sidebar( - key: sidebarKey, - collapseButton: collapseButton, - events: widget.events, - onUpdate: initialize, - ); - }, - ), - ]), - ), - PhysicalModel( - color: Colors.transparent, - elevation: 4.0, - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: minTimelineHeight, - maxHeight: maxTimelineHeight, - minWidth: double.infinity, - ), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Card( - margin: EdgeInsets.zero, - shape: const RoundedRectangleBorder(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - const Icon(null), - const Spacer(), - Text( - '${(_speed ?? timelineController.speed) == 1.0 ? '1' : (_speed ?? timelineController.speed).toStringAsFixed(1)}x', - ), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 120.0), - child: Slider( - value: _speed ?? timelineController.speed, - min: 0.5, - max: 2.0, - onChanged: (s) => setState(() => _speed = s), - onChangeEnd: (s) { - _speed = null; - timelineController.speed = s; - }, - ), - ), - Tooltip( - message: - timelineController.isPaused ? loc.play : loc.pause, - child: CircleAvatar( - child: Material( - type: MaterialType.transparency, - child: InkWell( - borderRadius: BorderRadius.circular(100.0), - onTap: () { - if (timelineController.isPaused) { - timelineController.play(context); - } else { - timelineController.pause(); - } - }, - child: Center( - child: Icon( - timelineController.isPaused - ? Icons.play_arrow - : Icons.pause, - ), - ), - ), - ), - ), - ), - const SizedBox(width: 20.0), - Icon(() { - final volume = _volume ?? timelineController.volume; - if ((_volume == null || _volume == 0.0) && - (timelineController.isMuted || volume == 0.0)) { - return Icons.volume_off; - } else if (volume < 0.5) { - return Icons.volume_down; - } else { - return Icons.volume_up; - } - }()), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 120.0), - child: Slider( - value: _volume ?? - (timelineController.isMuted - ? 0.0 - : timelineController.volume), - onChanged: (v) => setState(() => _volume = v), - onChangeEnd: (v) { - _volume = null; - timelineController.volume = v; - }, - ), - ), - ConstrainedBox( - constraints: const BoxConstraints(minWidth: 26.0), - child: Text( - timelineController.isMuted - ? '0' - : ((_volume ?? timelineController.volume) * 100) - .toStringAsFixed(0), - ), - ), - const Spacer(), - IconButton( - icon: const Icon(Icons.filter_list), - tooltip: loc.filter, - onPressed: () => showFilter(context), - ), - ]), - Row(children: [ - const SizedBox(width: 8.0), - SizedBox( - width: kDeviceNameWidth, - child: Text(loc.device), - ), - const Spacer(), - if (timelineController.initialized) - RepaintBoundary( - child: AnimatedBuilder( - animation: timelineController.positionNotifier, - builder: (context, child) { - final date = - timelineController.currentItem!.start; - - return AutoSizeText( - '${settings.dateFormat.format(date)}' - ' ' - '${DateFormat.Hms().format(date.add(timelineController.thumbPrecision))}', - minFontSize: 8.0, - maxFontSize: 13.0, - ); - }, - ), - ), - const Spacer(), - ]), - Expanded( - child: Material( - child: TimelineView( - timelineController: timelineController, - ), - ), - ), - ], - ), - ), - ), - SizedBox( - width: 220.0, - child: Card( - margin: EdgeInsets.zero, - shape: const RoundedRectangleBorder(), - child: Container( - alignment: AlignmentDirectional.center, - padding: const EdgeInsets.all(14.0), - child: timelineController.currentEvent == null - ? const Center(child: Text('No events')) - : EventTile.buildContent( - context, - timelineController.currentEvent!, - ), - ), - ), - ), - ]), - ), - ), - ]); - - return page; - } -} - -class Sidebar extends StatelessWidget { - final EventsData events; - final Widget collapseButton; - final VoidCallback onUpdate; - - const Sidebar({ - super.key, - required this.events, - required this.collapseButton, - required this.onUpdate, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final events = context.watch(); - final serversProvider = context.watch(); - final loc = AppLocalizations.of(context); - - final servers = serversProvider.servers.where((server) => server.devices - .any((d) => this.events.keys.contains(EventsProvider.idForDevice(d)))); - - return Material( - child: Column(children: [ - if (servers.isEmpty) - Expanded(child: Center(child: Text(loc.noServersAvailable))) - else - Expanded( - child: ListView.builder( - padding: EdgeInsetsDirectional.only( - bottom: MediaQuery.viewPaddingOf(context).bottom, - ), - itemCount: servers.length, - itemBuilder: (context, index) { - final server = servers.elementAt(index); - - final isLoading = serversProvider.isServerLoading(server); - - final devices = server.devices - .where((device) => this - .events - .keys - .contains(EventsProvider.idForDevice(device))) - .sorted(); - - return Column(children: [ - SubHeader( - server.name, - subtext: server.online - ? loc.nDevices(devices.length) - : loc.offline, - subtextStyle: TextStyle( - color: !server.online ? theme.colorScheme.error : null, - ), - padding: const EdgeInsetsDirectional.only( - start: 16.0, - end: 6.0, - ), - trailing: isLoading - ? const SizedBox( - height: 16.0, - width: 16.0, - child: CircularProgressIndicator.adaptive( - strokeWidth: 1.5, - ), - ) - : index == 0 - ? collapseButton - : null, - ), - ...devices.map((device) { - final selected = events.selectedIds - .contains(EventsProvider.idForDevice(device)); - - return _DeviceTile( - device: device, - selected: selected, - onUpdate: () async { - onUpdate(); - }, - ); - }), - ]); - }, - ), - ), - ]), - ); - } -} - -class _DeviceTile extends StatefulWidget { - const _DeviceTile({ - required this.device, - required this.selected, - required this.onUpdate, - }); - - final Device device; - final bool selected; - final VoidCallback onUpdate; - - @override - State<_DeviceTile> createState() => _DesktopDeviceSelectorTileState(); -} - -class _DesktopDeviceSelectorTileState extends State<_DeviceTile> { - PointerDeviceKind? currentLongPressDeviceKind; - - @override - Widget build(BuildContext context) { - // subscribe to media query updates - MediaQuery.of(context); - - final theme = Theme.of(context); - final events = context.watch(); - - return InkWell( - onTap: !widget.device.status - ? null - : () async { - if (widget.selected) { - await events.remove(widget.device); - } else { - await events.add(widget.device); - } - widget.onUpdate(); - }, - child: SizedBox( - height: 30.0, - child: Row(children: [ - const SizedBox(width: 16.0), - Container( - height: 6.0, - width: 6.0, - margin: const EdgeInsetsDirectional.only(end: 8.0), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: widget.device.status - ? theme.extension()!.successColor - : theme.colorScheme.error, - ), - ), - Expanded( - child: Text( - widget.device.name.uppercaseFirst(), - style: theme.textTheme.titleMedium!.copyWith( - color: widget.selected - ? theme.colorScheme.primary - : !widget.device.status - ? theme.disabledColor - : null, - ), - ), - ), - const SizedBox(width: 16.0), - ]), - ), - ); - } -} - -class FilterDialog extends StatelessWidget { - final FilterData? filter; - final FutureValueChanged onFilter; - - const FilterDialog({ - super.key, - required this.filter, - required this.onFilter, - }); - - @override - Widget build(BuildContext context) { - final loc = AppLocalizations.of(context); - final settings = SettingsProvider.instance; - - return SizedBox( - width: 280.0, - child: AlertDialog( - title: Text(loc.filter), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FilterTile( - title: loc.fromDate, - trailing: filter == null - ? '--' - : settings.dateFormat.format(filter!.from), - onTap: filter == null - ? null - : () async { - final date = await showDatePicker( - context: context, - initialDate: filter!.from, - firstDate: filter!.fromLimit, - lastDate: filter!.to, - initialEntryMode: DatePickerEntryMode.calendarOnly, - ); - - if (date != null) { - onFilter(filter!.copyWith( - from: date, - )); - } - }, - ), - FilterTile( - title: loc.toDate, - trailing: filter == null - ? '--' - : settings.dateFormat.format(filter!.to), - onTap: filter == null - ? null - : () async { - final date = await showDatePicker( - context: context, - initialDate: filter!.to, - firstDate: filter!.from, - lastDate: filter!.toLimit, - initialEntryMode: DatePickerEntryMode.calendarOnly, - ); - - if (date != null) { - onFilter(filter!.copyWith( - to: date, - )); - } - }, - ), - FilterTile.checkbox( - checked: filter?.allowAlarms, - onChanged: filter == null - ? null - : (v) { - onFilter( - filter!.copyWith( - allowAlarms: !filter!.allowAlarms, - ), - ); - }, - title: Text(loc.allowAlarms), - ), - ], - ), - actions: [ - ElevatedButton( - onPressed: Navigator.of(context).pop, - child: Text(loc.finish), - ), - ], - ), - ); - } -} - -class FilterTile extends StatelessWidget { - final String title; - final String trailing; - final VoidCallback? onTap; - - const FilterTile({ - super.key, - required this.title, - required this.trailing, - required this.onTap, - }); - - static Widget checkbox({ - required bool? checked, - required ValueChanged? onChanged, - required Widget title, - }) { - return Row(children: [ - Expanded(child: title), - Checkbox( - value: checked, - onChanged: onChanged, - tristate: true, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ]); - } - - @override - Widget build(BuildContext context) { - return Row(mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - width: 40.0, - child: Text( - title, - maxLines: 1, - ), - ), - const SizedBox(width: 4.0), - Expanded( - child: Material( - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: Text( - trailing, - maxLines: 1, - textAlign: TextAlign.end, - ), - ), - ), - ), - ), - ]); - } -} diff --git a/lib/widgets/events_playback/events_playback_mobile.dart b/lib/widgets/events_playback/events_playback_mobile.dart deleted file mode 100644 index d3662e68..00000000 --- a/lib/widgets/events_playback/events_playback_mobile.dart +++ /dev/null @@ -1,265 +0,0 @@ -/* - * This file is a part of Bluecherry Client (https://github.com/bluecherrydvr/unity). - * - * Copyright 2022 Bluecherry, LLC - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License as - * published by the Free Software Foundation; either version 3 of - * the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:bluecherry_client/models/device.dart'; -import 'package:bluecherry_client/providers/events_playback_provider.dart'; -import 'package:bluecherry_client/providers/server_provider.dart'; -import 'package:bluecherry_client/providers/settings_provider.dart'; -import 'package:bluecherry_client/utils/extensions.dart'; -import 'package:bluecherry_client/utils/theme.dart'; -import 'package:bluecherry_client/widgets/device_grid/device_grid.dart'; -import 'package:bluecherry_client/widgets/device_selector_screen.dart'; -import 'package:bluecherry_client/widgets/error_warning.dart'; -import 'package:bluecherry_client/widgets/events_playback/events_playback.dart'; -import 'package:bluecherry_client/widgets/events_playback/timeline_controller.dart'; -import 'package:bluecherry_client/widgets/misc.dart'; -import 'package:bluecherry_client/widgets/reorderable_static_grid.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; -import 'package:unity_video_player/unity_video_player.dart'; - -class EventsPlaybackMobile extends EventsPlaybackWidget { - const EventsPlaybackMobile({ - super.key, - required super.events, - required super.filter, - required super.onFilter, - }); - - @override - State createState() => _EventsPlaybackMobileState(); -} - -class _EventsPlaybackMobileState extends EventsPlaybackState { - @override - Widget buildChild(BuildContext context) { - final loc = AppLocalizations.of(context); - // final home = context.watch(); - final settings = context.watch(); - final serversProvider = context.watch(); - final servers = serversProvider.servers.where((server) => server.devices - .any( - (d) => widget.events.keys.contains(EventsProvider.idForDevice(d)))); - - final eventsProvider = context.watch(); - final minTimelineHeight = kTimelineTileHeight * - // at least the height of 2 - timelineController.tiles.length.clamp( - 2, - double.infinity, - ); - final maxTimelineHeight = - (kTimelineTileHeight * timelineController.tiles.length) - .clamp(minTimelineHeight, double.infinity) + - 70.0; // 70 is the height of the controls bar - - return Column(children: [ - Expanded( - child: ColoredBox( - color: Colors.black, - child: () { - if (!timelineController.initialized) { - return const SizedBox.expand(); - } else if (timelineController.tiles.isEmpty) { - return Center( - child: Text( - loc.selectACamera, - style: const TextStyle(color: Colors.white), - ), - ); - } else { - return Center( - child: AspectRatio( - aspectRatio: 16 / 9, - child: StaticGrid( - reorderable: false, - crossAxisCount: calculateCrossAxisCount( - timelineController.tiles.length, - ), - childAspectRatio: 16 / 9, - onReorder: eventsProvider.onReorder, - children: timelineController.tiles.map((tile) { - final has = - timelineController.currentItem is! TimelineGap && - tile.events - .hasForDate(timelineController.currentDate); - - final color = - createTheme(themeMode: ThemeMode.dark).canvasColor; - - return IndexedStack( - index: !has ? 0 : 1, - children: [ - Container( - color: color, - alignment: AlignmentDirectional.center, - padding: const EdgeInsets.all(12.0), - child: AutoSizeText( - loc.noRecords, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white), - ), - ), - - /// This ensures a faster initialization of the video view - /// providing a smoother experience. This isn't a good solution, - /// just a workaround for now - UnityVideoView( - player: tile.player, - color: color, - paneBuilder: (context, player) { - if (player.dataSource == null) { - return const ErrorWarning(message: ''); - } else { - // debugPrint('${player.dataSource}'); - } - - return const SizedBox.shrink(); - }, - ), - ], - ); - }).toList(), - ), - ), - ); - } - }(), - ), - ), - const SizedBox(height: 8.0), - Material( - type: MaterialType.transparency, - child: Stack(children: [ - const SizedBox(height: kToolbarHeight), - PositionedDirectional( - start: 8.0, - child: Row(children: [ - const UnityDrawerButton(), - const SizedBox(width: 8.0), - IconButton( - icon: const Icon(Icons.device_hub), - onPressed: () async { - final device = await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const DeviceSelectorScreen(), - ), - ); - if (device is Device && !eventsProvider.contains(device)) { - eventsProvider - ..clear() - ..add(device); - initialize(); - } - }, - ) - ]), - ), - PositionedDirectional( - end: 8.0, - child: IconButton( - icon: const Icon(Icons.filter_list), - tooltip: loc.filter, - onPressed: () => showFilter(context), - ), - ), - Row(children: [ - const Spacer(), - Tooltip( - message: timelineController.isPaused ? loc.play : loc.pause, - child: CircleAvatar( - child: Material( - type: MaterialType.transparency, - child: InkWell( - borderRadius: BorderRadius.circular(100.0), - onTap: () { - if (timelineController.isPaused) { - timelineController.play(context); - } else { - timelineController.pause(); - } - }, - child: Center( - child: Icon( - timelineController.isPaused - ? Icons.play_arrow - : Icons.pause, - ), - ), - ), - ), - ), - ), - const Spacer(), - ]), - ]), - ), - if (timelineController.initialized) - RepaintBoundary( - child: AnimatedBuilder( - animation: timelineController.positionNotifier, - builder: (context, child) { - final date = timelineController.currentItem!.start; - - return AutoSizeText( - '${settings.dateFormat.format(date)}' - ' ' - '${DateFormat.Hms().format(date.add(timelineController.thumbPrecision))}', - minFontSize: 8.0, - maxFontSize: 13.0, - ); - }, - ), - ) - else - const Text(''), - const SizedBox(height: 6.0), - PhysicalModel( - color: Colors.transparent, - elevation: 4.0, - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: minTimelineHeight, - maxHeight: maxTimelineHeight, - minWidth: double.infinity, - ), - child: Material( - child: !timelineController.initialized - // home.loadReasons - // .contains(UnityLoadingReason.fetchingEventsPlaybackPeriods) - ? const Center(child: CircularProgressIndicator.adaptive()) - : servers.isEmpty - ? Center( - child: Text( - loc.noServersAvailable, - ), - ) - : TimelineView( - timelineController: timelineController, - showDevicesName: false, - ), - ), - ), - ), - ]); - } -} diff --git a/lib/widgets/events_playback/timeline_controller.dart b/lib/widgets/events_playback/timeline_controller.dart deleted file mode 100644 index 269ed674..00000000 --- a/lib/widgets/events_playback/timeline_controller.dart +++ /dev/null @@ -1,1398 +0,0 @@ -/* - * This file is a part of Bluecherry Client (https://github.com/bluecherrydvr/unity). - * - * Copyright 2022 Bluecherry, LLC - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License as - * published by the Free Software Foundation; either version 3 of - * the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -import 'dart:async'; -import 'dart:math' as math; - -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:bluecherry_client/models/event.dart'; -import 'package:bluecherry_client/providers/events_playback_provider.dart'; -import 'package:bluecherry_client/providers/home_provider.dart'; -import 'package:bluecherry_client/providers/server_provider.dart'; -import 'package:bluecherry_client/utils/extensions.dart'; -import 'package:bluecherry_client/utils/theme.dart'; -import 'package:bluecherry_client/widgets/events_playback/events_playback.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; -import 'package:provider/provider.dart'; - -import 'package:unity_video_player/unity_video_player.dart'; - -const kDeviceNameWidth = 140.0; -const kTimelineTileHeight = 24.0; - -const kTimelineThumbWidth = 10.0; -const kTimelineThumbOverflowPadding = 20.0; - -class TimelineTile { - final String deviceId; - final List events; - final UnityVideoPlayer player; - - const TimelineTile({ - required this.deviceId, - required this.events, - required this.player, - }); -} - -extension TimelineTileExtension on List { - /// Get the correspondent tile for the given events - /// - /// Returns null if there is no correspondent tile - TimelineTile? forEvent(Event event) { - if (any((tile) => tile.events.contains(event))) { - final tile = firstWhere((tile) => tile.events.contains(event)); - return tile; - } - - return null; - } -} - -abstract class TimelineItem { - final DateTime start; - final DateTime end; - - final Duration duration; - - const TimelineItem(this.start, this.end, this.duration); - - /// Checks if the item [e] is in between the span of the given [date] - /// - /// See also: - /// - /// * [DateTime.isInBetween] - bool checkForDate(DateTime date) { - if (this is TimelineGap) { - return date.isInBetween(start, end); - } else if (this is TimelineValue) { - return (this as TimelineValue).events.hasForDate(date); - } else { - throw UnsupportedError('$runtimeType is not supported'); - } - } - - /// Returns a list with useful data - /// - /// * 0 - List - /// * 1 - Duration - /// * 2 - Oldest [Event] - /// * 3 - Newest [Event] - static List calculateTimeline(Iterable data) { - final allEvents = (data as Iterable) - .where( - (e) => e.duration > Duration.zero, - ) - .toList(growable: false) - ..sort((a, b) => a.published.compareTo(b.published)); - - if (allEvents.isEmpty) return []; - - final oldestEvent = allEvents.oldest; - final newestEvent = allEvents.newest; - - final oldest = oldestEvent.published; - final newest = newestEvent.published; - - final items = []; - var currentDateTime = oldest; - TimelineItem? currentItem; - - // The interval time is the duration of the shortest event - final intervalMs = () { - final eventsDuration = allEvents.map((e) => e.duration).toList() - ..sort((a, b) => a.compareTo(b)); - return eventsDuration.first; - }() - .inMilliseconds; - - final intervalTime = Duration( - /// min interval time is 0.5 seconds, max is 3 seconds - milliseconds: intervalMs.clamp(500, 3000), - ); - - debugPrint( - 'Generating timeline with ${allEvents.length}, ' - 'and with an interval time of $intervalTime', - ); - - void increment() => currentDateTime = currentDateTime.add(intervalTime); - - /// A loop that runs until the newest event date has been reached - /// - /// [currentItem] means current item for the current date time. - /// - /// If null, we create a new timeline item: - /// * If there is events for the current date time, a [TimelineValue] is - /// created - /// * Otherwise, a [TimelineGap] is created - /// - /// If not null, we check for the current date time and update the current - /// item accordingly: - /// * If it's a gap but there is an event for the current date time, the - /// gap is ended - /// * If it's a value but there is no longer any event in the current time - /// span, the value is ended - while (!currentDateTime.hasForDate(newest)) { - if (currentItem == null) { - final forDate = allEvents.forDateList(currentDateTime); - if (forDate.isNotEmpty) { - final start = forDate.oldest.published; - currentItem = TimelineValue( - start: start, - end: currentDateTime, - duration: Duration.zero, - events: forDate, - ); - increment(); - } else { - final start = items.isEmpty ? currentDateTime : items.last.end; - currentItem = TimelineGap( - start: start, - gapDuration: Duration.zero, - end: currentDateTime, - ); - increment(); - } - } else { - if (allEvents.hasForDate(currentDateTime)) { - // ends the gap - if (currentItem is TimelineGap) { - final event = allEvents.forDateList(currentDateTime).oldest; - currentItem = TimelineGap( - start: currentItem.start, - end: event.published, - gapDuration: event.published - .difference(currentItem.start) - .ensurePositive(), - ); - items.add(currentItem); - currentItem = null; - } else { - increment(); - } - } else { - // ends a timeline value - if (currentItem is TimelineValue) { - final events = allEvents - .inBetween(currentItem.start, currentDateTime) - .toList() - .sublist(0, 1); - - var duration = Duration.zero; - Event? previous; - for (final event in events) { - if (previous == null) { - previous = event; - final eventDuration = event.duration; - - duration = duration + eventDuration; - } else { - // the gap between the two events - final previousEnd = previous.published.add(previous.duration); - final difference = previousEnd.difference(event.published); - duration = duration + difference; - - final eventDuration = event.duration; - duration = duration + eventDuration; - } - } - - currentItem = TimelineValue( - start: currentItem.start, - end: currentItem.start.add(duration.ensurePositive()), - duration: duration.ensurePositive(), - events: events, - ); - items.add(currentItem); - currentItem = null; - } else { - increment(); - } - } - } - } - - return [ - items, - oldestEvent, - newestEvent, - ]; - } -} - -class TimelineValue extends TimelineItem { - final Iterable events; - - const TimelineValue({ - required this.events, - required DateTime start, - required DateTime end, - required Duration duration, - }) : super(start, end, duration); - - @override - String toString() { - return 'TimelineValue(events: ${events.length}, start: $start, end: $end, duration: $duration)'; - } - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is TimelineValue && - other.events.length == events.length && - other.start == start && - other.end == end && - other.duration == duration; - } - - @override - int get hashCode { - return events.hashCode ^ start.hashCode ^ end.hashCode ^ duration.hashCode; - } -} - -class TimelineGap extends TimelineItem { - const TimelineGap({ - required DateTime start, - required DateTime end, - required Duration gapDuration, - }) : super(start, end, gapDuration); - - @override - String toString() => - 'TimelineGap(gapDuration: $duration, start: $start, end: $end)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - - return other is TimelineGap && - other.duration == duration && - other.start == start && - other.end == end; - } - - @override - int get hashCode => duration.hashCode ^ start.hashCode ^ end.hashCode; -} - -/// Controls the state of a [TimelineView]. -/// -/// Before using, make sure the controller is initialize by caling [initialize]. -/// If tried to use without being initialized, an error is thrown. -/// -/// This controller ensures all the timeline items are in sync with each other. -/// It also manages the gaps between [TimelineValue]s and skip them properly -/// -/// While in a gap, the upcoming video is precached for a smooth transition. -/// -/// Use [setDate] for seeking. It requires the initial item position and its -/// precision. It is responsible for seeking the tile player as well. -class TimelineController extends ChangeNotifier { - /// Controls the timeline view scrolling - final scrollController = ScrollController(); - - /// The horizontal extent of the timeline in pixels - double get timelineExtent { - if (!scrollController.hasClients || - !scrollController.position.hasViewportDimension) { - return 0.0; - } - return scrollController.position.viewportDimension; - } - - /// The width of a millisecond. This takes [zoom] into consideration - /// - /// See also: - /// - /// * [zoom], which represents if the timeline is compacted or expanded - double get periodWidth { - if (items.isEmpty) return 0.0; - final timelineDuration = items - .map((e) { - if (e is TimelineGap) return gapDuration; - - return e.duration; - }) - .reduce((a, b) => a + b) - .inMilliseconds; - - return timelineExtent / timelineDuration * zoom; - } - - /// The duration of a gap in the timeline - /// - /// This should not be used to calculate the current item. Instead, this should - /// only be used to calculate the width and position of the timeline thumb - Duration get gapDuration { - if (zoom < maxZoom / 4) return Duration.zero; - - return const Duration(seconds: 5); - } - - /// The width of a gap - double get gapWidth { - return clampDouble( - gapDuration.inMilliseconds * periodWidth, - 50, - double.infinity, - ); - } - - /// All the tiles of the timeline. Usually represents the devices in a server - List tiles = []; - - /// The oldest event of the timeline - Event? oldest; - - /// The newest event of the timeline - Event? newest; - - /// The position of the current item, considering the gaps - late final positionNotifier = ChangeNotifier(); - - /// The thumb position is calculated in the following way: - /// - /// We account all the previous items and their respective duration, and - /// position the thumb accordingly. For a precise match, [thumbPrecision] is - /// used. It is updated by the current player (if any) or the ticker. - /// - /// [thumbPrecision] is reset every time the current item changes - Duration thumbPrecision = Duration.zero; - Duration get thumbPosition { - return _thumbPosition(currentDate, thumbPrecision, item: currentItem); - } - - Duration _thumbPosition( - DateTime forDate, - Duration precision, { - TimelineItem? item, - }) { - item ??= itemForDate(forDate); - if (item == null) return Duration.zero; - - final previousItems = items.where((i) => i.end.isBefore(forDate)); - - var pos = previousItems.fold( - Duration.zero, - (duration, item) { - if (item is TimelineGap) return duration + gapDuration; - - return duration + item.duration; - }, - ); - - final thumbPrecision = - precision > item.duration ? Duration.zero : precision; - - return pos + thumbPrecision; - } - - /// This makes animating with gap possible - /// - /// When it reaches [gapDuration], we move to the next item. While the gap is - /// running, we precache the next items - Duration currentGapDuration = Duration.zero; - - /// The current date of the timeline, in real time - /// - /// The initial date is the date of start of the oldest event - DateTime currentDate = DateTime(0); - - /// The current item span of the timeline - TimelineItem? _currentItem; - TimelineItem? get currentItem => _currentItem; - - /// Sets the current item and resets the player for the given tile - /// - /// When the item is updated, it's found necessary to reset the player position - /// for a smooth experience. It's more noticable in a multiple tile situation - set currentItem(TimelineItem? item) { - debugPrint( - 'Changing item ' - '${currentItem.runtimeType} (${currentItem?.start}) ' - 'to ${item.runtimeType} (${item?.start})', - ); - - if (currentItem is TimelineValue) { - final tile = tiles.forEvent((currentItem as TimelineValue).events.first); - if (tile != null) { - tile.player.reset(); - } - } - _currentItem = item; - } - - /// The current event in the currentItem - /// - /// If there is no events available, null is returned - Event? get currentEvent { - if (currentItem == null || currentItem is TimelineGap) return null; - - final events = (currentItem as TimelineValue).events; - if (!events.hasForDate(currentDate)) return null; - - return events.forDate(currentDate); - } - - double _speed = 1; - double get speed => _speed; - set speed(double speed) { - for (final tile in tiles) { - tile.player.setSpeed(speed); - } - - _speed = speed; - notifyListeners(); - } - - double _volume = 1; - double get volume => _volume; - set volume(double volume) { - for (final tile in tiles) { - tile.player.setVolume(volume); - } - - _muted = false; - _volume = volume; - notifyListeners(); - } - - bool _muted = false; - bool get isMuted => _muted; - void mute() { - _muted = true; - for (final tile in tiles) { - tile.player.setVolume(0); - } - - notifyListeners(); - } - - void unmute() { - _muted = false; - for (final tile in tiles) { - tile.player.setVolume(volume); - } - notifyListeners(); - } - - /// Zoom represents the expansion of the timeline - /// - /// 1.0 is the smaller state. It is also the initial state. On this state, all - /// [items] will fit within the timeline. - /// - /// 2.0 is the max value. On this state, all items can be easily viewed and - /// differentiated to the human eye - double _zoom = 1.0; - double get zoom => _zoom; - set zoom(double zoom) { - if (zoom < minZoom || zoom > maxZoom) return; - - _zoom = zoom.clamp(minZoom, maxZoom); - notifyListeners(); - ensureThumbVisible(); - } - - static const double minZoom = 1.0; - double get maxZoom => (items.length) / 6; - // double get maxZoom => 30.0; - - /// All the events in the timeline - /// - /// See also: - /// - /// * [TimelineValue], an item that can play media - /// * [TimelineGap], an item that doesn't have any media during a timespan - List items = []; - - /// Usually, if no events were found, it probably means there is a gap between - /// events in a single [TimelineItem]. >>should<< be normal. - /// - /// This usually doesn't happen because, when creating the timeline (See TimelineItem.calculateTimeline), - /// the span used is the duration of the smallest event. If it happens, something - /// may have gone wrong. Yet, this is not going to break the timeline, since - /// the next event is going to be played after the next gap - TimelineItem? itemForDate(DateTime date) { - return items.any((e) => e.checkForDate(date)) - ? items.firstWhere((e) => e.checkForDate(date)) - : currentItem; - } - - /// A ticker that runs every interval - Timer? timer; - void startTimer(BuildContext context) { - // do not initialize it twice, otherwise it may cause inconsistency - if (timer != null && timer!.isActive) return; - if (oldest == null || newest == null) return; - - final home = context.read(); - const interval = Duration(milliseconds: 25); - - void reset() { - currentDate = oldest!.published; - currentItem = null; - thumbPrecision = Duration.zero; - - for (final tile in tiles) { - tile.player.reset(); - } - positionNotifier.notifyListeners(); - } - - /// Whether this is past the end - bool has() { - return items.any((e) => currentDate.isInBetween(e.start, e.end)); - } - - if (!has()) { - timer?.cancel(); - reset(); - } - - timer = Timer.periodic(interval, (timer) async { - if (!has()) { - timer.cancel(); - reset(); - - return; - } - - /// Usually, if no events were found, it probably means there is a gap between - /// events in a single [TimelineItem]. >>should<< be normal. - /// - /// This usually doesn't happen because, when creating the timeline (See TimelineItem.calculateTimeline), - /// the span used is the duration of the smallest event. If it happens, something - /// may have gone wrong. Yet, this is not going to break the timeline, since - /// the next event is going to be played after the next gap - final itemForDate = items.any((i) => i.checkForDate(currentDate)) - ? items.firstWhere((i) => i.checkForDate(currentDate)) - : currentItem; - - /// When the item changes, we ensure to change it and update the ticker - if (currentItem != itemForDate) { - currentItem = itemForDate; - thumbPrecision = Duration.zero; - notifyListeners(); - } - - /// If the current item is a gap, we add it according to the (current ticker duration * speed) - /// When it reaches the max gap duration (gapDuration), it preloads the next item. - if (currentItem is TimelineGap) { - addGap(currentItem!.duration, interval * speed); - - final nextDate = currentDate.add(currentItem!.duration); - - /// We check for the next event and assign its media source, if necessary - if (items.any((e) => e is TimelineValue && e.checkForDate(nextDate))) { - final next = items - .whereType() - .firstWhere((value) => value.checkForDate(nextDate)); - - for (final event in next.events) { - final tile = tiles.forEvent(event); - if (tile == null) continue; - - final mediaUrl = event.mediaURL?.toString(); - - if (!event.isAlarm && - mediaUrl != null && - tile.player.dataSource != mediaUrl) { - debugPrint('PRELOADING $mediaUrl'); - tile.player.setDataSource( - mediaUrl, - autoPlay: false, - ); - } - } - } - } else if (currentItem is TimelineValue) { - /// If all the events in the timeline are alarms, we add it according to - /// the (current ticker duration * speed) - /// - /// Otherwise, the duration that is added is the *difference* between the - /// last added position and the current position - /// - /// Check the listener for [onCurrentPosUpdate] in [initialize] for more - /// info on how this is done - if ((currentItem! as TimelineValue).events.hasForDate(currentDate)) { - final allEvents = - (currentItem! as TimelineValue).events.forDateList(currentDate); - if (allEvents.where((event) => event.isAlarm).length == - allEvents.length) { - final precision = interval * speed; - currentDate = currentDate.add(precision); - thumbPrecision = thumbPrecision + precision; - positionNotifier.notifyListeners(); - } - } - } - - if (currentItem is TimelineValue) { - final events = (currentItem as TimelineValue).events; - - /// Ensure the current event is playing - if (events.hasForDate(currentDate)) { - for (final event in events.forDateList(currentDate)) { - if (event.mediaURL == null) continue; - - final tile = tiles.forEvent(event); - if (tile == null) continue; - - if (tile.player.dataSource == event.mediaURL!.toString()) { - if (!tile.player.isPlaying) { - await tile.player.start(); - } - } else { - home.loading(UnityLoadingReason.timelineEventLoading); - await tile.player.setDataSource( - event.mediaURL!.toString(), - ); - home.notLoading(UnityLoadingReason.timelineEventLoading); - notifyListeners(); - } - } - } - } - }); - } - - /// Sets the timeline at the provided [date] - /// - /// [precision] determines the precision of the pointer - void setDate(DateTime date, Duration precision) { - if (currentDate.hasForDate(date)) return; - final item = itemForDate(date); - - if (item == null) { - throw ArgumentError( - '$date is not a valid timespan of the current timeline', - ); - } - - // If it's the current item, we seek for the precision - if (currentItem == item && item is TimelineValue) { - debugPrint('Item for date $date is already the current item'); - for (final event in item.events) { - final tile = tiles.forEvent(event); - if (tile == null) continue; - - final mediaUrl = event.mediaURL?.toString(); - - if (!event.isAlarm && - mediaUrl != null && - tile.player.dataSource == mediaUrl) { - debugPrint('SEEKING $mediaUrl TO $precision'); - tile.player.seekTo(precision); - } - } - return; - } - - currentItem = item; - currentDate = item.start.add(precision); - if (item is TimelineGap) { - currentGapDuration = precision; - thumbPrecision = precision; - - // this avoids reseting the thumbPrecision, since it's reset when the item - // is changed - currentItem = item; - - debugPrint('seeking gap to $precision'); - } else if (item is TimelineValue) { - thumbPrecision = Duration.zero; - } else { - throw UnsupportedError( - '${currentItem.runtimeType} is not a supported item', - ); - } - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - notifyListeners(); - }); - - debugPrint( - '(${item.runtimeType})' - ' $date = i${item.start}' - ' precision $precision', - ); - } - - void setVideoPosition(Duration precision) { - // to avoid issues on the client - if (currentItem == null) return; - - assert(currentItem != null); - assert(currentItem is TimelineValue); - - final desiredDate = currentItem!.start.add(precision); - - if (currentDate.isBefore(desiredDate)) { - currentDate = desiredDate; - } - thumbPrecision = precision; - - positionNotifier.notifyListeners(); - _updateThumbPosition(); - } - - /// If the gap duration has ended, it [add]s into the current position - /// - /// Otherwise, it adds [position] to the current thumb position - void addGap(Duration duration, Duration position) { - assert(currentItem is TimelineGap); - - if (currentGapDuration >= gapDuration) { - currentGapDuration = thumbPrecision = Duration.zero; - currentDate = currentItem!.start.add(duration + position); - } else { - thumbPrecision = thumbPrecision + position; - currentGapDuration = currentGapDuration + position; - } - positionNotifier.notifyListeners(); - _updateThumbPosition(); - } - - /// Checks for the current scroll position of the timeline. If the thumb is - /// reaching the end of the timeline, scroll to ensure the thumb is visible - /// - /// See also: - /// - /// * [ensureThumbVisible], which ensure the thumb is somewhere in between - /// [timelineExtent] - void _updateThumbPosition() { - if (scrollController.hasClients) { - final thumbX = - thumbPosition.inMilliseconds * periodWidth - scrollController.offset; - - final to = scrollController.offset + kTimelineThumbOverflowPadding / 2; - - if (thumbX.toInt() >= timelineExtent.toInt()) { - scrollController.jumpTo(to); - } else if (thumbX > timelineExtent - kTimelineThumbOverflowPadding) { - scrollController.animateTo( - to, - duration: const Duration(milliseconds: 200), - curve: Curves.linear, - ); - } - } - } - - /// Makes the thumb visible in the start-ish of the timeline - /// - /// It jumps to the start of the [currentItem]. If the thumb is overflown, - /// we ensure it is visible by calling [_updateThumbPosition] - void ensureThumbVisible() { - if (currentItem == null) return; - - scrollController.jumpTo( - _thumbPosition( - currentItem!.start, - Duration.zero, - ).inMilliseconds * - periodWidth, - ); - - // avoid any overflow - _updateThumbPosition(); - } - - /// Whether this controller is initialized - /// - /// See also: - /// - /// * [initialize], which initializes the timeline view - bool get initialized { - return items.isNotEmpty && currentItem != null; - } - - /// [events] all the events split by device - /// - /// [allEvents] all events in the history - Future initialize( - BuildContext context, - EventsData events, - List allEvents, - ) async { - HomeProvider.instance.loading( - UnityLoadingReason.fetchingEventsPlaybackPeriods, - notify: false, - ); - - _clear(); - notifyListeners(); - - if (!context.mounted || allEvents.isEmpty) { - HomeProvider.instance.notLoading( - UnityLoadingReason.fetchingEventsPlaybackPeriods, - notify: false, - ); - return; - } - - /// Only generate tiles for the devices that are selected - final selectedIds = context.read().selectedIds; - for (final event - in events.entries.where((e) => selectedIds.contains(e.key))) { - final id = event.key; - final events = event.value; - final item = TimelineTile( - deviceId: id, - events: events, - player: UnityVideoPlayer.create(), - ); - item.player - ..setSpeed(speed) - ..setVolume(volume) - ..onCurrentPosUpdate.listen((pos) { - if (item.events.hasForDate(currentDate)) { - setVideoPosition(pos); - if (pos.inMilliseconds.isEven) debugPrint('pos $pos'); - } - }) - ..onPlayingStateUpdate.listen((isPlayerPlaying) { - // If the video is being played but the timeline is paused for some reason - if (isPlayerPlaying && isPaused) pause(); - notifyListeners(); - }) - ..onBufferStateUpdate.listen((buffering) { - /// If the current item is paused to buffer, we show the loading indicator - /// and pause the current timeline - if (buffering) { - context - .read() - .loading(UnityLoadingReason.timelineEventLoading); - // pause(); - } else { - context - .read() - .notLoading(UnityLoadingReason.timelineEventLoading); - // play(context); - } - }); - tiles.add(item); - } - - final result = await compute(TimelineItem.calculateTimeline, allEvents); - if (result.isNotEmpty) { - items = result[0] as List; - oldest = result[1] as Event; - newest = result[2] as Event; - currentDate = oldest!.published; - - if (context.mounted) { - startTimer(context); - notifyListeners(); - } - } - debugPrint(items.toString()); - - HomeProvider.instance.notLoading( - UnityLoadingReason.fetchingEventsPlaybackPeriods, - ); - } - - /// Starts all players - Future play(BuildContext context) async { - startTimer(context); - notifyListeners(); - } - - /// Checks if the current player state is paused - /// - /// If a single player is paused, all players will be paused. - bool get isPaused { - return timer == null || !timer!.isActive; - } - - /// Pauses all players - Future pause() async { - timer?.cancel(); - await Future.wait(tiles.map((i) => i.player.pause())); - - notifyListeners(); - } - - @protected - void _clear() { - timer?.cancel(); - timer = null; - - for (final tile in tiles) { - tile.player.release(); - tile.player.dispose(); - } - tiles.clear(); - items.clear(); - - oldest = newest = _currentItem = null; - - thumbPrecision = currentGapDuration = Duration.zero; - currentDate = DateTime(0); - } - - @override - Future dispose() async { - _clear(); - positionNotifier.dispose(); - scrollController.dispose(); - super.dispose(); - } -} - -class TimelineView extends StatefulWidget { - final TimelineController timelineController; - final bool showDevicesName; - - const TimelineView({ - super.key, - required this.timelineController, - this.showDevicesName = true, - }); - - @override - State createState() => _TimelineViewState(); -} - -class _TimelineViewState extends State { - TimelineController get controller => widget.timelineController; - - /// Whether the mouse is being pressed - bool isPressing = false; - Offset pointerPosition = Offset.zero; - bool _initiallyPaused = false; - - @override - void initState() { - super.initState(); - GestureBinding.instance.pointerRouter.addGlobalRoute(_handlePointerEvent); - } - - @override - void dispose() { - GestureBinding.instance.pointerRouter - .removeGlobalRoute(_handlePointerEvent); - super.dispose(); - } - - void _handlePointerEvent(PointerEvent event) { - var pressing = false; - if (event is PointerUpEvent || event is PointerCancelEvent) { - if (!_initiallyPaused) controller.play(context); - _initiallyPaused = false; - pressing = false; - } else if (event is PointerDownEvent) { - _initiallyPaused = controller.isPaused; - pressing = true; - } - - if (pressing && context.mounted) { - final renderBox = context.findRenderObject() as RenderBox; - if (renderBox.attached) { - final renderRect = - renderBox.localToGlobal(Offset.zero) & renderBox.size; - if (renderRect.contains(event.position)) { - isPressing = event.down; - - if (isPressing) { - controller.pause(); - } - - setState(() => pointerPosition = event.position); - } - } - } - } - - @override - Widget build(BuildContext context) { - // subscribe to window size changes - MediaQuery.sizeOf(context); - - final servers = context.watch().servers; - - final deviceNameWidth = widget.showDevicesName ? kDeviceNameWidth : 0.0; - - final theme = Theme.of(context).extension()!; - final timelineBox = Stack(children: [ - Positioned.fill( - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (widget.showDevicesName) ...[ - SizedBox( - width: deviceNameWidth + 2.0, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: controller.tiles.map((tile) { - final device = servers.findDevice(tile.deviceId)!; - final server = - servers.firstWhere((s) => s.devices.contains(device)); - return Tooltip( - message: '${server.name}/${device.name}', - preferBelow: false, - verticalOffset: 12.0, - child: Container( - height: kTimelineTileHeight, - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Row(children: [ - Flexible( - flex: 2, - child: AutoSizeText( - server.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - maxFontSize: 12.0, - ), - ), - Expanded( - flex: 3, - child: AutoSizeText( - '/${device.name}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - maxFontSize: 12.0, - ), - ), - ]), - ), - ); - }).toList(), - ), - ), - const VerticalDivider(width: 2.0), - ], - Expanded( - child: SingleChildScrollView( - controller: controller.scrollController, - scrollDirection: Axis.horizontal, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: controller.tiles.map((tile) { - return Container( - height: kTimelineTileHeight, - decoration: const BoxDecoration( - border: Border(bottom: BorderSide()), - ), - child: Row( - children: controller.items.map((item) { - if (item is TimelineGap) { - return _TimelineItemGestures( - controller: controller, - width: controller.gapWidth, - item: item, - isPressing: isPressing, - pointerPosition: pointerPosition, - child: Container( - height: kTimelineTileHeight, - padding: const EdgeInsets.symmetric(horizontal: 5), - alignment: AlignmentDirectional.center, - color: theme.gapColor, - child: AutoSizeText( - item.duration.humanReadableCompact(context), - maxLines: 1, - minFontSize: 8.0, - maxFontSize: 10.0, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.black), - ), - ), - ); - } else if (item is TimelineValue) { - final events = item.events - .where((event) => tile.events.contains(event)); - // .inBetween(i.start, i.end); - - final width = item.duration.inMilliseconds * - controller.periodWidth; - - if (events.isEmpty) { - return SizedBox(width: width); - } - return _TimelineItemGestures( - controller: controller, - width: width, - item: item, - isPressing: isPressing, - pointerPosition: pointerPosition, - child: SizedBox( - height: kTimelineTileHeight, - child: () { - Widget buildForEvent( - Event? event, - Duration duration, - ) { - return Container( - height: kTimelineTileHeight, - width: duration.inMilliseconds * - controller.periodWidth, - color: event == null - ? null - : event.isAlarm - ? theme.alarmColor - : theme.eventColor, - alignment: AlignmentDirectional.center, - // child: AutoSizeText( - // duration.humanReadableCompact(context), - // maxLines: 1, - // maxFontSize: 12, - // minFontSize: 8, - // textAlign: TextAlign.center, - // ), - ); - } - - var widgets = []; - - Event? previous; - for (final event in events) { - if (previous == null) { - previous = event; - - widgets.add( - buildForEvent(event, event.duration)); - } else { - final previousEnd = - previous.published.add(previous.duration); - final difference = - previousEnd.difference(event.published); - - widgets.add(buildForEvent(null, difference)); - - final duration = event.duration; - widgets.add(buildForEvent(event, duration)); - } - } - - return Row(children: widgets); - }(), - ), - ); - } else { - throw UnsupportedError( - '${item.runtimeType} is not a supported type', - ); - } - }).toList()), - ); - }).toList(), - ), - ), - ), - ]), - ), - if (controller.initialized) ...[ - Positioned.fill( - left: deviceNameWidth, - child: RepaintBoundary( - child: AnimatedBuilder( - animation: Listenable.merge([ - controller.scrollController, - controller.positionNotifier, - ]), - builder: (context, child) { - final scrollOffset = controller.scrollController.hasClients - ? controller.scrollController.offset - : 0.0; - final x = controller.thumbPosition.inMilliseconds * - controller.periodWidth - - scrollOffset; - - if (x.isNegative) { - return const SizedBox(); - } - - return Stack(clipBehavior: Clip.none, children: [ - Positioned( - top: 0.0, - bottom: 0.0, - left: x - 3, - width: kTimelineThumbWidth, - child: child!, - ), - Positioned( - left: x - 10, - width: kTimelineThumbWidth, - top: 0.0, - bottom: -10.0, - child: Align( - alignment: AlignmentDirectional.bottomCenter, - child: Icon( - Icons.arrow_drop_up, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ]); - }, - child: IgnorePointer( - child: SizedBox( - width: kTimelineThumbWidth, - child: Center( - child: Container( - width: 2.5, - color: Theme.of(context).colorScheme.onSurface, - ), - ), - ), - ), - ), - ), - ), - ], - ]); - - return Listener( - onPointerSignal: _receivedPointerSignal, - child: timelineBox, - ); - } - - // Handle mousewheel and web trackpad scroll events. - void _receivedPointerSignal(PointerSignalEvent event) { - final double scaleChange; - if (event is PointerScrollEvent) { - if (event.kind == PointerDeviceKind.trackpad) { - return; - } - // Ignore left and right mouse wheel scroll. - if (event.scrollDelta.dy == 0.0) { - return; - } - scaleChange = math.exp(event.scrollDelta.dy / 200); - } else if (event is PointerScaleEvent) { - scaleChange = event.scale; - } else { - return; - } - if (scaleChange < 1.0) { - controller.zoom -= 0.8; - } else { - controller.zoom += 0.6; - } - } -} - -class _TimelineItemGestures extends StatefulWidget { - final TimelineController controller; - final TimelineItem item; - final double width; - - final bool isPressing; - final Offset pointerPosition; - - final Widget child; - - const _TimelineItemGestures({ - required this.controller, - required this.item, - required this.width, - required this.isPressing, - required this.pointerPosition, - required this.child, - }); - - static const kPopupWidth = 60.0; - - @override - State<_TimelineItemGestures> createState() => _TimelineItemGesturesState(); -} - -class _TimelineItemGesturesState extends State<_TimelineItemGestures> { - Offset? localPosition; - DateTime? date; - - bool get showIndicator { - if (!mounted || !widget.isPressing) return false; - - final renderBox = context.findRenderObject() as RenderBox; - if (!renderBox.hasSize) return false; - final pos = renderBox.localToGlobal(Offset.zero) & renderBox.size; - - return pos.contains(widget.pointerPosition); - } - - @override - void didUpdateWidget(_TimelineItemGestures oldWidget) { - super.didUpdateWidget(oldWidget); - - if (showIndicator) { - final previous = widget.controller.items - .where((e) => e.end.isBefore(widget.item.start)); - - final renderBox = context.findRenderObject() as RenderBox; - localPosition = renderBox.globalToLocal(widget.pointerPosition); - - final ms = localPosition!.dx / widget.controller.periodWidth; - final duration = Duration(milliseconds: ms.toInt()); - - DateTime? date; - if (previous.isEmpty) { - date = widget.item.start.add(duration); - } else if (widget.item is TimelineValue) { - date = widget.item.start.add(duration); - } else if (widget.item is TimelineGap) { - date = widget.item.start.add(widget.item.duration); - } - - this.date = date; - - if (date != null) widget.controller.setDate(date, duration); - } - } - - @override - Widget build(BuildContext context) { - if (widget.width == 0.0) return const SizedBox.shrink(); - - final theme = Theme.of(context).extension()!; - - return IgnorePointer( - child: SizedBox( - width: widget.width, - child: Stack(clipBehavior: Clip.none, children: [ - widget.child, - if (localPosition != null && date != null && showIndicator) - PositionedDirectional( - start: localPosition!.dx - _TimelineItemGestures.kPopupWidth, - child: Container( - height: kTimelineTileHeight, - width: _TimelineItemGestures.kPopupWidth, - color: theme.seekPopupColor, - padding: const EdgeInsets.symmetric(horizontal: 8.0), - margin: const EdgeInsetsDirectional.only(bottom: 2.5), - alignment: AlignmentDirectional.center, - child: AutoSizeText( - DateFormat.Hms().format(date!), - maxLines: 1, - minFontSize: 8.0, - ), - ), - ), - ]), - ), - ); - } -} diff --git a/lib/widgets/events_timeline/desktop/timeline.dart b/lib/widgets/events_timeline/desktop/timeline.dart new file mode 100644 index 00000000..9dda1c1a --- /dev/null +++ b/lib/widgets/events_timeline/desktop/timeline.dart @@ -0,0 +1,657 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:bluecherry_client/models/device.dart'; +import 'package:bluecherry_client/models/event.dart'; +import 'package:bluecherry_client/providers/settings_provider.dart'; +import 'package:bluecherry_client/utils/extensions.dart'; +import 'package:bluecherry_client/widgets/device_grid/device_grid.dart' + show calculateCrossAxisCount; +import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline_card.dart'; +import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline_sidebar.dart'; +import 'package:bluecherry_client/widgets/events_timeline/events_playback.dart'; +import 'package:bluecherry_client/widgets/misc.dart'; +import 'package:bluecherry_client/widgets/reorderable_static_grid.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; +import 'package:unity_video_player/unity_video_player.dart'; + +final timelineTimeFormat = DateFormat('hh:mm:ss a'); + +class TimelineTile { + final Device device; + final List events; + + late final UnityVideoPlayer videoController; + + TimelineTile({ + required this.device, + required this.events, + }) { + videoController = UnityVideoPlayer.create( + quality: UnityVideoQuality.p480, + enableCache: true, + ); + } +} + +class TimelineEvent { + /// The duration of the event + final Duration duration; + + /// When the event started + final DateTime startTime; + + final String videoUrl; + + final Event event; + + TimelineEvent({ + required this.duration, + required this.startTime, + required this.event, + this.videoUrl = + 'https://user-images.githubusercontent.com/28951144/229373695-22f88f13-d18f-4288-9bf1-c3e078d83722.mp4', + }); + + static List get fakeData { + return [ + TimelineEvent( + duration: const Duration(minutes: 1), + startTime: DateTime(2023).add( + Duration(hours: Random().nextInt(4), minutes: Random().nextInt(60)), + ), + event: Event.dump(), + ), + TimelineEvent( + duration: const Duration(hours: 1), + startTime: DateTime(2023).add(Duration(hours: Random().nextInt(4) + 5)), + event: Event.dump(), + ), + TimelineEvent( + duration: const Duration(minutes: 1), + startTime: DateTime(2023).add(Duration(hours: Random().nextInt(4) + 9)), + event: Event.dump(), + ), + TimelineEvent( + duration: const Duration(minutes: 1), + startTime: DateTime(2023).add( + Duration( + hours: Random().nextInt(4) + 13, + minutes: Random().nextInt(60), + ), + ), + event: Event.dump(), + ), + TimelineEvent( + duration: const Duration(minutes: 1), + startTime: DateTime(2023).add( + Duration( + hours: Random().nextInt(4) + 14, + minutes: Random().nextInt(60), + ), + ), + event: Event.dump(), + ), + TimelineEvent( + duration: const Duration(minutes: 1), + startTime: DateTime(2023).add( + Duration( + hours: Random().nextInt(4) + 20, + minutes: Random().nextInt(60), + ), + ), + event: Event.dump(), + ), + ]; + } + + DateTime get endTime => startTime.add(duration); + + bool isPlaying(DateTime currentDate) { + return currentDate.isInBetween(startTime, endTime); + } + + /// The position of the video at the [currentDate] + Duration position(DateTime currentDate) { + return currentDate.difference(startTime); + } +} + +/// A timeline of events +/// +/// Events are played as they happened in time. The timeline is limited to a +/// single day, so events are from hour 0 to 23. +class Timeline extends ChangeNotifier { + /// Each tile of the timeline + final List tiles = []; + + /// All the events must have happened in the same day + final DateTime date; + + Timeline({required List tiles, required this.date}) { + add(tiles.where((tile) => tile.events.isNotEmpty)); + + for (final tile in this.tiles) { + tile.videoController + ..onBufferUpdate.listen((_) => _eventCallback(tile)) + ..onDurationUpdate.listen((_) => _eventCallback(tile)) + ..onPlayingStateUpdate.listen((_) => _eventCallback(tile)) + ..onCurrentPosUpdate.listen((_) => _eventCallback(tile, notify: false)); + } + } + + void _eventCallback(TimelineTile tile, {bool notify = true}) { + if (tile.videoController.duration <= Duration.zero) return; + + final index = tiles.indexOf(tile); + + final bufferFactor = tile.videoController.currentBuffer.inMilliseconds / + tile.videoController.duration.inMilliseconds; + + // This only applies if the video is not buffered + if (bufferFactor < 1.0) { + if (tile.videoController.currentBuffer < + tile.videoController.currentPos) { + debugPrint('should pause for buffering'); + pausedToBuffer.add(index); + stop(); + } else if (pausedToBuffer.contains(index)) { + debugPrint('should play from buffering'); + pausedToBuffer.remove(index); + + if (pausedToBuffer.isEmpty) play(); + } + } else if (pausedToBuffer.contains(index)) { + debugPrint('should play from buffering'); + pausedToBuffer.remove(index); + + if (pausedToBuffer.isEmpty) play(); + } + if (notify) notifyListeners(); + } + + Timeline.placeholder() : date = DateTime(2023); + + static Timeline get fakeTimeline { + return Timeline( + date: DateTime(2023), + tiles: [ + TimelineTile( + device: Device.dump(name: 'device1'), + events: TimelineEvent.fakeData, + ), + TimelineTile( + device: Device.dump(name: 'device2'), + events: TimelineEvent.fakeData, + ), + TimelineTile( + device: Device.dump(name: 'device3'), + events: TimelineEvent.fakeData, + ), + TimelineTile( + device: Device.dump(name: 'device4'), + events: TimelineEvent.fakeData, + ), + ], + ); + } + + void add(Iterable tiles) { + assert(tiles.every((tile) { + return tile.events.every((event) => + event.startTime.year == date.year && + event.startTime.month == date.month && + event.startTime.day == date.day); + }), 'All events must have happened in the same day'); + this.tiles.addAll(tiles); + assert( + this.tiles.length <= kMaxDevicesOnScreen, + 'There must be at most $kMaxDevicesOnScreen devices on screen', + ); + notifyListeners(); + } + + void removeTile(TimelineTile tile) { + tiles.remove(tile); + notifyListeners(); + } + + void forEachEvent( + void Function(TimelineTile tile, TimelineEvent event) callback) { + for (var tile in tiles) { + for (var event in tile.events) { + callback(tile, event); + } + } + } + + /// The current position of the timeline + var currentPosition = const Duration(); + + DateTime get currentDate => date.add(currentPosition); + + void seekTo(Duration position) { + currentPosition = position; + notifyListeners(); + + forEachEvent((tile, event) { + if (!event.isPlaying(currentDate)) return; + tile.videoController.setDataSource(event.videoUrl); + + final position = event.position(currentDate); + tile.videoController.seekTo(position); + if (!isPlaying) tile.videoController.pause(); + + debugPrint('Seeking ${tile.device} to $position'); + }); + } + + /// Seeks forward by [duration] + void seekForward([Duration duration = const Duration(seconds: 15)]) => + seekTo(currentPosition + duration); + + /// Seeks backward by [duration] + void seekBackward([Duration duration = const Duration(seconds: 15)]) => + seekTo(currentPosition - duration); + + double _volume = 1.0; + bool get isMuted => volume == 0; + double get volume => _volume; + set volume(double value) { + _volume = value; + notifyListeners(); + + for (final tile in tiles) { + tile.videoController.setVolume(volume); + } + } + + double _speed = 1.0; + double get speed => _speed; + set speed(double value) { + _speed = value; + stop(); + notifyListeners(); + + for (final tile in tiles) { + tile.videoController.setSpeed(speed); + } + + play(); + } + + double _zoom = 1.0; + double get zoom => _zoom; + set zoom(double value) { + _zoom = value; + notifyListeners(); + } + + Timer? timer; + bool get isPlaying => timer != null && timer!.isActive; + + /// The indexes of the tiles that are paused to buffer + Set pausedToBuffer = {}; + void stop() { + if (timer == null) return; + + timer?.cancel(); + timer = null; + + for (final tile in tiles) { + tile.videoController.pause(); + } + notifyListeners(); + } + + void play([TimelineEvent? event]) { + timer ??= Timer.periodic(Duration(milliseconds: 1000 ~/ _speed), (timer) { + if (event == null) currentPosition += const Duration(seconds: 1); + notifyListeners(); + + forEachEvent((tile, e) { + if (tile.videoController.isPlaying) return; + + if ((event != null && event == e) || e.isPlaying(currentDate)) { + if (event == null) { + tile.videoController.seekTo(e.position(currentDate)); + } + if (!tile.videoController.isPlaying) tile.videoController.start(); + } + }); + }); + notifyListeners(); + } + + @override + void dispose() { + stop(); + for (final tile in tiles) { + tile.videoController.dispose(); + } + super.dispose(); + } +} + +const _kDeviceNameWidth = 100.0; +const _kTimelineTileHeight = 30.0; +final _minutesInADay = const Duration(days: 1).inMinutes; + +class TimelineEventsView extends StatefulWidget { + final Timeline? timeline; + + const TimelineEventsView({super.key, required this.timeline}); + + @override + State createState() => _TimelineEventsViewState(); +} + +class _TimelineEventsViewState extends State { + double? _speed; + double? _volume; + + @override + void initState() { + super.initState(); + widget.timeline?.addListener(_updateCallback); + } + + void _updateCallback() { + if (mounted) setState(() {}); + } + + Timeline get timeline => widget.timeline ?? Timeline.placeholder(); + + @override + void didUpdateWidget(covariant TimelineEventsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.timeline != oldWidget.timeline) { + oldWidget.timeline?.removeListener(_updateCallback); + widget.timeline?.addListener(_updateCallback); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context); + final settings = context.watch(); + + return Column(children: [ + Expanded( + child: Row(children: [ + Expanded( + child: Center( + child: AspectRatio( + aspectRatio: 16 / 9, + child: Center( + child: StaticGrid( + padding: EdgeInsets.zero, + reorderable: false, + crossAxisCount: calculateCrossAxisCount( + timeline.tiles.length, + ), + onReorder: (a, b) {}, + childAspectRatio: 16 / 9, + emptyChild: Center(child: Text(loc.noEventsFound)), + children: timeline.tiles.map((tile) { + return TimelineCard(tile: tile, timeline: timeline); + }).toList(), + ), + ), + ), + ), + ), + TimelineSidebar(timeline: timeline), + ]), + ), + Card( + margin: const EdgeInsetsDirectional.only( + start: 4.0, + end: 4.0, + bottom: 4.0, + ), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only( + topStart: Radius.circular(12.0), + bottomStart: Radius.circular(12.0), + bottomEnd: Radius.circular(12.0), + ), + ), + clipBehavior: Clip.antiAlias, + child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [ + Padding( + padding: const EdgeInsetsDirectional.only( + bottom: 4.0, + top: 2.0, + start: 8.0, + end: 8.0, + ), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Expanded( + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + if (timeline.pausedToBuffer.isNotEmpty) + Container( + height: 22.0, + width: 22.0, + margin: const EdgeInsetsDirectional.only(end: 8.0), + child: const CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ), + Text( + '${(_speed ?? timeline.speed) == 1.0 ? '1' : (_speed ?? timeline.speed).toStringAsFixed(1)}' + 'x', + ), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120.0), + child: Slider( + value: _speed ?? timeline.speed, + min: 0.5, + max: 2.0, + onChanged: (s) => setState(() => _speed = s), + onChangeEnd: (s) { + _speed = null; + timeline.speed = s; + FocusScope.of(context).unfocus(); + }, + ), + ), + ]), + ), + const SizedBox(width: 20.0), + IconButton( + tooltip: timeline.isPlaying ? loc.pause : loc.play, + icon: PlayPauseIcon(isPlaying: timeline.isPlaying), + onPressed: () { + setState(() { + if (timeline.isPlaying) { + timeline.stop(); + } else { + timeline.play(); + } + }); + }, + ), + const SizedBox(width: 20.0), + Expanded( + child: Row(children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120.0), + child: Slider( + value: + _volume ?? (timeline.isMuted ? 0.0 : timeline.volume), + onChanged: (v) => setState(() => _volume = v), + onChangeEnd: (v) { + _volume = null; + timeline.volume = v; + FocusScope.of(context).unfocus(); + }, + ), + ), + Icon(() { + final volume = _volume ?? timeline.volume; + if ((_volume == null || _volume == 0.0) && + (timeline.isMuted || volume == 0.0)) { + return Icons.volume_off; + } else if (volume < 0.5) { + return Icons.volume_down; + } else { + return Icons.volume_up; + } + }()), + ]), + ), + ]), + ), + Text( + '${settings.dateFormat.format(timeline.currentDate)} ' + '${timelineTimeFormat.format(timeline.currentDate)}', + ), + FractionallySizedBox( + widthFactor: timeline.zoom, + child: LayoutBuilder(builder: (context, constraints) { + final minuteWidth = + (constraints.maxWidth - _kDeviceNameWidth) / _minutesInADay; + + return Stack(children: [ + Column(children: [ + Row(children: [ + const SizedBox(width: _kDeviceNameWidth), + ...List.generate(24, (index) { + final hour = index + 1; + if (hour == 24) { + return const Expanded(child: SizedBox.shrink()); + } + + return Expanded( + child: Transform.translate( + offset: Offset( + hour.toString().length * 4, + 0.0, + ), + child: Text( + '$hour', + style: theme.textTheme.labelMedium, + textAlign: TextAlign.end, + ), + ), + ); + }), + ]), + GestureDetector( + onHorizontalDragUpdate: (details) { + final pointerPosition = + (details.localPosition.dx - _kDeviceNameWidth) / + (constraints.maxWidth - _kDeviceNameWidth); + if (pointerPosition < 0 || pointerPosition > 1) return; + + final minutes = + (_minutesInADay * pointerPosition).round(); + final position = Duration(minutes: minutes); + timeline.seekTo(position); + }, + child: Column(children: [ + ...timeline.tiles.map((tile) { + return _TimelineTile( + key: ValueKey(tile), + tile: tile, + ); + }), + ]), + ) + ]), + Positioned( + left: (timeline.currentPosition.inMinutes * minuteWidth) + + _kDeviceNameWidth, + width: 1.8, + top: 16.0, + height: _kTimelineTileHeight * timeline.tiles.length, + child: IgnorePointer( + child: ColoredBox( + color: theme.colorScheme.onBackground, + ), + ), + ), + ]); + }), + ), + ]), + ), + ]); + } +} + +class _TimelineTile extends StatelessWidget { + final TimelineTile tile; + + const _TimelineTile({super.key, required this.tile}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final border = Border( + right: BorderSide(color: theme.disabledColor.withOpacity(0.5)), + top: BorderSide(color: theme.dividerColor.withOpacity(0.5)), + ); + + return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + width: _kDeviceNameWidth, + height: _kTimelineTileHeight, + padding: const EdgeInsets.symmetric(horizontal: 4.0), + decoration: BoxDecoration( + color: theme.dialogBackgroundColor, + border: border, + ), + alignment: AlignmentDirectional.centerStart, + child: RichText( + maxLines: 1, + text: TextSpan( + style: theme.textTheme.labelMedium, + children: [ + TextSpan(text: tile.device.name), + TextSpan( + text: ' (${tile.events.length})', + style: const TextStyle(fontSize: 11.0), + ), + ], + ), + ), + ), + ...List.generate(24, (index) { + final hour = index; + + return Expanded( + child: Container( + height: _kTimelineTileHeight, + decoration: BoxDecoration(border: border), + child: LayoutBuilder(builder: (context, constraints) { + if (!tile.events.any((event) => event.startTime.hour == hour)) { + return const SizedBox.shrink(); + } + + final minuteWidth = constraints.maxWidth / 60; + return Stack(clipBehavior: Clip.none, children: [ + for (final event in tile.events + .where((event) => event.startTime.hour == hour)) + Positioned( + left: event.startTime.minute * minuteWidth, + width: event.duration.inMinutes * minuteWidth, + height: _kTimelineTileHeight, + child: ColoredBox( + color: theme.colorScheme.primary, + ), + ), + ]); + }), + ), + ); + }), + ]); + } +} diff --git a/lib/widgets/events_timeline/desktop/timeline_card.dart b/lib/widgets/events_timeline/desktop/timeline_card.dart new file mode 100644 index 00000000..d9e625a0 --- /dev/null +++ b/lib/widgets/events_timeline/desktop/timeline_card.dart @@ -0,0 +1,150 @@ +import 'package:bluecherry_client/utils/extensions.dart'; +import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline.dart'; +import 'package:bluecherry_client/widgets/hover_button.dart'; +import 'package:bluecherry_client/widgets/misc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:unity_video_player/unity_video_player.dart'; + +class TimelineCard extends StatelessWidget { + const TimelineCard({super.key, required this.tile, required this.timeline}); + + final Timeline timeline; + final TimelineTile tile; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context); + + final device = tile.device; + final events = tile.events; + + final currentEvent = events.firstWhereOrNull((event) { + return event.isPlaying(timeline.currentDate); + }); + + const showDebugInfo = kDebugMode; + + return Card( + key: ValueKey(device), + clipBehavior: Clip.antiAliasWithSaveLayer, + color: Colors.transparent, + surfaceTintColor: Colors.transparent, + child: UnityVideoView( + heroTag: device.streamURL, + player: tile.videoController, + color: Colors.transparent, + paneBuilder: (context, controller) { + if (currentEvent == null) { + return RepaintBoundary( + child: Material( + type: MaterialType.card, + color: theme.colorScheme.surface, + surfaceTintColor: theme.colorScheme.surfaceTint, + elevation: 1.0, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Stack(children: [ + Text( + device.name, + style: theme.textTheme.titleMedium, + ), + Center( + child: Text( + loc.noRecords, + textAlign: TextAlign.center, + ), + ), + ]), + ), + ), + ); + } + return HoverButton( + forceEnabled: true, + margin: const EdgeInsets.all(16.0), + builder: (_, states) => Stack(clipBehavior: Clip.none, children: [ + RichText( + text: TextSpan( + text: '', + style: theme.textTheme.labelLarge!.copyWith( + color: Colors.white, + shadows: outlinedText(strokeWidth: 0.75), + ), + children: [ + TextSpan( + text: device.name, + style: theme.textTheme.titleMedium!.copyWith( + color: Colors.white, + shadows: outlinedText(strokeWidth: 0.75), + ), + ), + const TextSpan(text: '\n'), + TextSpan( + text: currentEvent + .position(timeline.currentDate) + .humanReadableCompact(context), + ), + if (showDebugInfo) ...[ + const TextSpan(text: '\ndebug: '), + TextSpan( + text: + controller.currentPos.humanReadableCompact(context), + ), + ], + ], + ), + ), + if (showDebugInfo) + Align( + alignment: AlignmentDirectional.topEnd, + child: Text( + 'debug buffering: ' + '${(tile.videoController.currentBuffer.inMilliseconds / tile.videoController.duration.inMilliseconds).toStringAsPrecision(2)}' + '\n${tile.videoController.currentBuffer.humanReadableCompact(context)}', + style: theme.textTheme.labelLarge!.copyWith( + color: Colors.white, + shadows: outlinedText(strokeWidth: 0.75), + ), + ), + ), + if (controller.isBuffering) + const PositionedDirectional( + end: 0, + top: 0, + height: 24.0, + width: 24.0, + child: CircularProgressIndicator.adaptive(strokeWidth: 2.0), + ), + if (states.isHovering) + Align( + alignment: AlignmentDirectional.bottomStart, + child: RichText( + text: TextSpan( + style: theme.textTheme.labelLarge!.copyWith( + color: Colors.white, + shadows: outlinedText(strokeWidth: 0.75), + ), + children: [ + TextSpan(text: '${loc.duration}: '), + TextSpan( + text: currentEvent.duration + .humanReadableCompact(context)), + const TextSpan(text: '\n'), + TextSpan(text: '${loc.eventType}: '), + TextSpan( + text: currentEvent.event.type.locale(context), + ), + ], + ), + ), + ), + ]), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/events_timeline/desktop/timeline_sidebar.dart b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart new file mode 100644 index 00000000..fcad79ba --- /dev/null +++ b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart @@ -0,0 +1,219 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:bluecherry_client/providers/settings_provider.dart'; +import 'package:bluecherry_client/utils/extensions.dart'; +import 'package:bluecherry_client/utils/tree_view/tree_view.dart'; +import 'package:bluecherry_client/widgets/device_grid/device_grid.dart'; +import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline.dart'; +import 'package:bluecherry_client/widgets/events_timeline/events_playback.dart'; +import 'package:bluecherry_client/widgets/misc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:provider/provider.dart'; + +class TimelineSidebar extends StatefulWidget { + const TimelineSidebar({super.key, required this.timeline}); + + final Timeline timeline; + + @override + State createState() => _TimelineSidebarState(); +} + +class _TimelineSidebarState extends State { + @override + Widget build(BuildContext context) { + final loc = AppLocalizations.of(context); + final settings = context.watch(); + + return Container( + constraints: kSidebarConstraints, + height: double.infinity, + child: Card( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.vertical( + top: Radius.circular(12.0), + ), + ), + margin: const EdgeInsetsDirectional.symmetric(horizontal: 4.0), + child: Column(children: [ + SubHeader(loc.servers, height: 40.0), + Expanded( + child: buildTreeView(context, setState: setState), + ), + if (kDebugMode) ...[ + const SubHeader('Time filter', height: 24.0), + ListTile( + title: AutoSizeText( + settings.dateFormat.format(widget.timeline.currentDate), + maxLines: 1, + ), + onTap: () async { + if (eventsPlaybackScreenKey.currentState == null) return; + final oldestDate = (eventsPlaybackScreenKey + .currentState!.realDevices.values + .expand((e) => e) + .toList() + ..sort((a, b) => a.published.compareTo(b.published))) + .first + .published; + + final date = await showDatePicker( + context: context, + initialDate: widget.timeline.currentDate, + firstDate: oldestDate, + lastDate: DateTime.now(), + initialEntryMode: DatePickerEntryMode.calendarOnly, + currentDate: widget.timeline.currentDate, + ); + debugPrint('date: $date'); + }, + ), + ], + ]), + ), + ); + } + + Widget buildTreeView( + BuildContext context, { + double checkboxScale = 0.8, + double gapCheckboxText = 0.0, + required void Function(VoidCallback fn) setState, + }) { + if (eventsPlaybackScreenKey.currentState == null) { + return const SizedBox.shrink(); + } + final state = eventsPlaybackScreenKey.currentState!; + + final theme = Theme.of(context); + final servers = state.devices.keys.map((d) => d.server).toSet(); + + return TreeView( + indent: 56, + iconSize: 18.0, + nodes: servers.map((server) { + final isTriState = state.disabledDevices.any(server.devices.contains); + final isOffline = !server.online; + + final serverDevices = + server.devices.where(state.realDevices.containsKey).sorted(); + + return TreeNode( + content: Row(children: [ + buildCheckbox( + value: isOffline || + !widget.timeline.tiles.any( + (tile) => server.devices.contains(tile.device), + ) + ? false + : isTriState + ? null + : true, + isError: isOffline, + onChanged: (v) { + if (isTriState || v == null || !v) { + for (final device in serverDevices) { + if (widget.timeline.tiles + .any((tile) => tile.device == device)) { + widget.timeline.removeTile( + widget.timeline.tiles + .firstWhere((tile) => tile.device == device), + ); + } + } + } else { + for (final device in serverDevices) { + widget.timeline.add([ + state.realDevices.entries + .firstWhere((e) => e.key == device) + .buildTimelineTile(), + ]); + } + } + + setState(() {}); + }, + checkboxScale: checkboxScale, + ), + SizedBox(width: gapCheckboxText), + Expanded( + child: Text( + server.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + ), + ), + Text( + '${serverDevices.length}', + style: theme.textTheme.labelSmall, + ), + const SizedBox(width: 10.0), + ]), + children: () { + if (isOffline) { + return []; + } else { + return serverDevices.map((device) { + final enabled = widget.timeline.tiles.any( + (tile) => tile.device == device, + ); + final eventsForDevice = state.devices[device]; + + return TreeNode( + content: Row(children: [ + IgnorePointer( + ignoring: !device.status, + child: buildCheckbox( + value: device.status ? enabled : false, + isError: !device.status, + onChanged: (v) { + if (!device.status) return; + + if (enabled && state.disabledDevices.length < 4) { + widget.timeline.removeTile( + widget.timeline.tiles.firstWhere( + (tile) => tile.device == device, + ), + ); + } else if (state.realDevices.entries + .any((e) => e.key == device) && + !enabled) { + widget.timeline.add([ + state.realDevices.entries + .firstWhere((e) => e.key == device) + .buildTimelineTile(), + ]); + } + setState(() {}); + }, + checkboxScale: checkboxScale, + ), + ), + SizedBox(width: gapCheckboxText), + Flexible( + child: Text( + device.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + ), + ), + if (eventsForDevice != null) ...[ + Text( + ' (${eventsForDevice.length})', + style: theme.textTheme.labelSmall, + ), + const SizedBox(width: 10.0), + ], + ]), + ); + }).toList(); + } + }(), + ); + }).toList(), + ); + } +} diff --git a/lib/widgets/events_timeline/events_playback.dart b/lib/widgets/events_timeline/events_playback.dart new file mode 100644 index 00000000..e5df310d --- /dev/null +++ b/lib/widgets/events_timeline/events_playback.dart @@ -0,0 +1,252 @@ +import 'package:bluecherry_client/api/api.dart'; +import 'package:bluecherry_client/models/device.dart'; +import 'package:bluecherry_client/models/event.dart'; +import 'package:bluecherry_client/providers/home_provider.dart'; +import 'package:bluecherry_client/providers/server_provider.dart'; +import 'package:bluecherry_client/utils/constants.dart'; +import 'package:bluecherry_client/utils/extensions.dart'; +import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline.dart'; +import 'package:bluecherry_client/widgets/events_timeline/mobile/timeline_device_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +final eventsPlaybackScreenKey = GlobalKey<_EventsPlaybackState>(); +const kMaxDevicesOnScreen = 6; + +class EventsPlayback extends StatefulWidget { + EventsPlayback() : super(key: eventsPlaybackScreenKey); + + @override + State createState() => _EventsPlaybackState(); +} + +class _EventsPlaybackState extends State { + Timeline? timeline; + final focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => fetch()); + focusNode.requestFocus(); + } + + @override + void dispose() { + timeline?.dispose(); + super.dispose(); + } + + Map> realDevices = {}; + Map> devices = {}; // filtered + + Set disabledDevices = {}; + + Future fetch([bool fetchFromServer = true]) async { + setState(() { + timeline?.dispose(); + timeline = null; + }); + final home = context.read() + ..loading(UnityLoadingReason.fetchingEventsPlayback); + var date = DateTime( + DateTime.now().year, + DateTime.now().month, + DateTime.now().day, + ); + for (final server in ServersProvider.instance.servers) { + if (!server.online) continue; + + final events = fetchFromServer + ? ((await API.instance.getEvents( + await API.instance.checkServerCredentials(server), + )) + .where((event) => event.mediaURL != null) + .toList() + ..sort((a, b) { + return a.published.compareTo(b.published); + })) + : realDevices.values.expand((e) => e).toList(); + + if (events.isEmpty) continue; + + // If there are any events for today, use today as the date + if (events.any( + (event) => DateUtils.isSameDay( + event.published.toUtc(), + DateTime.now().toUtc(), + ), + )) { + date = DateTime( + DateTime.now().year, + DateTime.now().month, + DateTime.now().day, + ); + } else { + // Otherwise, use the most recent date that has events + final recentDate = (events.toList() + ..sort((a, b) => b.published.compareTo(a.published))) + .first + .published; + date = DateTime( + recentDate.year, + recentDate.month, + recentDate.day, + ); + } + + for (final event in events) { + // If the event is not long enough to be displayed, do not add it + if (event.duration < const Duration(minutes: 1) || + event.mediaURL == null) { + continue; + } + + if (!DateUtils.isSameDay(event.published, date) || + !DateUtils.isSameDay(event.published.add(event.duration), date)) { + continue; + } + + final device = event.server.devices + .firstWhere((device) => device.id == event.deviceID); + + realDevices[device] ??= []; + + // If there is already an event that conflicts with this one in time, do + // not add it + if (realDevices[device]!.any((e) { + return e.published.isInBetween( + event.published, + event.published.add(event.duration), + ) || + e.published.add(e.duration).isInBetween( + event.published, + event.published.add(event.duration), + ) || + event.published.isInBetween( + e.published, + e.published.add(e.duration), + ) || + event.published.add(event.duration).isInBetween( + e.published, + e.published.add(e.duration), + ); + })) continue; + + realDevices[device] ??= []; + realDevices[device]!.add(event); + + // If there are more than kMaxDevicesOnScreen devices, do not add any more devices + if (disabledDevices.contains(device)) { + if (devices.containsKey(device)) devices.remove(device); + continue; + } else if (devices.length == kMaxDevicesOnScreen && + !devices.containsKey(device)) { + disabledDevices.add(device); + continue; + } + + devices[device] = realDevices[device]!; + } + } + + final parsedTiles = devices.entries.map((e) => e.buildTimelineTile()); + + home.notLoading(UnityLoadingReason.fetchingEventsPlayback); + + setState(() { + timeline = Timeline( + tiles: parsedTiles.toList(), + date: date, + ); + }); + } + + @override + Widget build(BuildContext context) { + return Focus( + autofocus: true, + focusNode: focusNode, + onKeyEvent: (node, event) { + if (timeline == null || + !(event is KeyDownEvent || event is KeyRepeatEvent)) { + return KeyEventResult.ignored; + } + + debugPrint(event.logicalKey.debugName); + if (event.logicalKey == LogicalKeyboardKey.space) { + if (timeline!.isPlaying) { + timeline!.stop(); + } else { + timeline!.play(); + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.keyM) { + if (timeline!.isMuted) { + timeline!.volume = 1.0; + } else { + timeline!.volume = 0.0; + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.keyR || + event.logicalKey == LogicalKeyboardKey.f5) { + fetch(); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + timeline!.seekForward(); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + timeline!.seekBackward(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + }, + child: LayoutBuilder(builder: (context, constraints) { + final hasDrawer = Scaffold.hasDrawer(context); + + if (hasDrawer || constraints.maxWidth < kMobileBreakpoint.width) { + if (timeline == null) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Stack(children: [ + if (hasDrawer) const DrawerButton(), + const Center( + child: CircularProgressIndicator.adaptive(), + ), + ]), + ), + ); + } + return SafeArea(child: TimelineDeviceView(timeline: timeline!)); + } + return TimelineEventsView( + // timeline: kDebugMode ? Timeline.fakeTimeline : timeline, + timeline: timeline, + ); + }), + ); + } +} + +extension DevicesMapExtension on MapEntry> { + TimelineTile buildTimelineTile() { + final device = key; + final events = value; + debugPrint('Loaded ${events.length} events for $device'); + + return TimelineTile( + device: device, + events: events.map((event) { + return TimelineEvent( + startTime: event.published, + duration: event.duration, + videoUrl: event.mediaURL!.toString(), + event: event, + ); + }).toList(), + ); + } +} diff --git a/lib/widgets/events_timeline/mobile/timeline_device_view.dart b/lib/widgets/events_timeline/mobile/timeline_device_view.dart new file mode 100644 index 00000000..680b575e --- /dev/null +++ b/lib/widgets/events_timeline/mobile/timeline_device_view.dart @@ -0,0 +1,757 @@ +import 'dart:async'; + +import 'package:bluecherry_client/models/device.dart'; +import 'package:bluecherry_client/models/event.dart'; +import 'package:bluecherry_client/providers/downloads_provider.dart'; +import 'package:bluecherry_client/providers/home_provider.dart'; +import 'package:bluecherry_client/providers/settings_provider.dart'; +import 'package:bluecherry_client/utils/extensions.dart'; +import 'package:bluecherry_client/utils/theme.dart'; +import 'package:bluecherry_client/widgets/device_selector_screen.dart'; +import 'package:bluecherry_client/widgets/downloads_manager.dart'; +import 'package:bluecherry_client/widgets/events_timeline/desktop/timeline.dart'; +import 'package:bluecherry_client/widgets/events_timeline/events_playback.dart'; +import 'package:bluecherry_client/widgets/misc.dart'; +import 'package:flutter/foundation.dart'; +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'; + +const _kEventSeparatorWidth = 8.0; + +class TimelineDeviceView extends StatefulWidget { + const TimelineDeviceView({super.key, required this.timeline}); + + final Timeline timeline; + + @override + State createState() => _TimelineDeviceViewState(); +} + +class _TimelineDeviceViewState extends State { + Device? device; + TimelineTile? get tile { + if (device == null) return null; + return widget.timeline.tiles.firstWhereOrNull((t) => t.device == device); + } + + DateTime? currentDate; + TimelineEvent? get currentEvent { + if (tile == null) return null; + + assert(currentDate != null, 'There must be a date'); + return tile?.events.firstWhereOrNull((event) { + return event.isPlaying(currentDate!); + }); + } + + int lastEventIndex = -1; + + /// Whether the user is scrolling the timeline. If true, [ensureScrollPosition] + /// will not execute to avoid conflicts + bool isScrolling = false; + final controller = ScrollController(); + + /// Select a device to show on the timeline + Future selectDevice(BuildContext context) async { + device = await showDeviceSelectorScreen( + context, + available: widget.timeline.tiles.map((t) => t.device), + selected: [if (tile?.device != null) tile!.device], + eventsPerDevice: widget.timeline.tiles.fold>( + {}, + (map, tile) { + map[tile.device] = tile.events.length; + return map; + }, + ), + ); + if (device != null) { + // If there is already a selected device, dispose it + setState(() { + positionSubscription = tile!.videoController.onCurrentPosUpdate + .listen(_tilePositionListener); + bufferingSubscription = tile!.videoController.onBufferStateUpdate + .listen((v) => setState(() => isBuffering = v)); + tile!.videoController.onBufferUpdate.listen((_) => _updateScreen()); + currentDate = tile!.events.first.event.published; + tile!.videoController.setDataSource(currentEvent!.videoUrl); + tile!.videoController.onPlayingStateUpdate + .listen((_) => _updateScreen()); + ensureScrollPosition(); + setEvent(tile!.events.first); + }); + } + } + + /// Make [event] the current event and seek to [position] + void setEvent(TimelineEvent event, [Duration? position]) { + Future seek() async { + if (position != null && position != tile!.videoController.currentPos) { + tile!.videoController.seekTo(position); + } + } + + if (currentEvent == event) { + seek(); + } else { + currentDate = event.event.published; + seek(); + ensureScrollPosition(const Duration(milliseconds: 650), Curves.ease); + } + + if (currentEvent != null) { + /// Ensure the data source is correct. If the data source is the same + /// nothing is done. + tile!.videoController.setDataSource(currentEvent!.videoUrl); + lastEventIndex = tile!.events.indexOf(currentEvent!); + } + } + + StreamSubscription? positionSubscription; + StreamSubscription? bufferingSubscription; + bool isBuffering = false; + Duration _lastPosition = Duration.zero; + void _tilePositionListener(Duration position) { + if (mounted) { + setState(() { + if (tile!.videoController.currentPos == + tile!.videoController.duration && + tile!.videoController.duration > Duration.zero) { + // If it's the last event, return + if (lastEventIndex == tile!.events.length - 1) { + return; + } + setEvent(tile!.events.elementAt(lastEventIndex + 1)); + } else if (currentEvent != null) { + currentDate = + currentEvent!.startTime.add(tile!.videoController.currentPos); + ensureScrollPosition(position - _lastPosition); + + _lastPosition = position; + } + }); + } + } + + void _updateScreen() { + if (mounted) setState(() {}); + } + + /// Ensure the scroll position is correct + Future ensureScrollPosition([ + Duration duration = const Duration(milliseconds: 100), + Curve curve = Curves.linear, + ]) async { + // If the event is null it means that there is no position to scroll to + // + // If the user is scrolling, don't scroll to the event + if (currentEvent == null || + isScrolling || + !controller.hasClients || + scrolledManually) { + return; + } + final eventsBefore = tile!.events.where( + (e) => e.event.published.isBefore(currentEvent!.event.published), + ); + + final eventsFactor = eventsBefore.isEmpty + ? Duration.zero + : eventsBefore.map((e) => e.duration).reduce((a, b) => a + b); + + scrolledManually = true; + await controller.animateTo( + // The scroll position is: + // + the position of the event + // + the position of the events before the current event + // + the width of the separators + eventsFactor.inDoubleSeconds + + currentEvent!.position(currentDate!).inDoubleSeconds + + eventsBefore.length * _kEventSeparatorWidth, + duration: duration > Duration.zero + ? duration + : const Duration(milliseconds: 100), + curve: curve, + ); + scrolledManually = false; + } + + /// Whether the video was playing when the user started scrolling + bool wasPlayingOnScroll = false; + + /// Whether the scroll view was scrolled programatically by [ensureScrollPosition] + bool scrolledManually = false; + void _onScrollStart(ScrollStartNotification notification) { + if (scrolledManually) return; + + isScrolling = true; + wasPlayingOnScroll = widget.timeline.isPlaying; + if (wasPlayingOnScroll) { + widget.timeline.play(currentEvent); + } + } + + void _onScrollUpdate(ScrollUpdateNotification notification) { + if (scrolledManually) return; + // _onScrollEnd(ScrollEndNotification( + // context: notification.context!, + // metrics: notification.metrics, + // )); + } + + void _onScrollEnd(ScrollEndNotification notification) { + if (scrolledManually) return; + + final scrollPosition = controller.position.pixels; + + for (final event in tile!.events) { + final eventsBefore = tile!.events.where( + (e) => e.event.published.isBefore(event.event.published), + ); + Duration eventsBeforeDuration() => + eventsBefore.map((e) => e.duration).reduce((a, b) => a + b); + + final especulatedStartPosition = eventsBefore.isEmpty + ? 0 + : (eventsBeforeDuration().inSeconds + + eventsBefore.length * _kEventSeparatorWidth) + .toInt(); + + final especulatedEndPosition = eventsBefore.isEmpty + ? event.duration.inSeconds + : (eventsBeforeDuration().inSeconds + + (eventsBefore.length * _kEventSeparatorWidth) + + event.duration.inSeconds); + + if (scrollPosition >= especulatedStartPosition && + scrollPosition <= especulatedEndPosition) { + final position = Duration( + seconds: scrollPosition.toInt() - especulatedStartPosition, + ); + setEvent(event, position); + debugPrint( + 'User scrolled to event #${tile!.events.indexOf(event)} at $position', + ); + break; + } + } + + isScrolling = false; + if (wasPlayingOnScroll) { + widget.timeline.play(); + } + } + + /// Enter the fullscreen mode + Future enterFullscreen(BuildContext context) async { + assert(currentEvent != null); + + if (tile != null) { + final isPlaying = widget.timeline.isPlaying; + if (isPlaying) widget.timeline.stop(); + await Navigator.of(context).pushNamed( + '/events', + arguments: { + 'event': currentEvent!.event, + 'upcoming': tile?.events.map((e) => e.event), + 'videoPlayer': tile?.videoController, + }, + ); + if (isPlaying) widget.timeline.play(currentEvent); + } + } + + @override + void dispose() { + tile?.videoController.dispose(); + positionSubscription?.cancel(); + bufferingSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loc = AppLocalizations.of(context); + final settings = context.watch(); + + final tile = this.tile; + + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: AspectRatio( + aspectRatio: 16 / 9, + child: () { + if (tile == null) { + return Card( + margin: EdgeInsets.zero, + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: () => selectDevice(context), + child: const Center(child: Icon(Icons.add, size: 42.0)), + ), + ); + } + + return UnityVideoView( + heroTag: currentEvent!.event.mediaURL, + player: tile.videoController, + paneBuilder: !kDebugMode + ? null + : (context, controller) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Stack(children: [ + RichText( + text: TextSpan( + style: theme.textTheme.labelMedium?.copyWith( + shadows: outlinedText(), + color: Colors.white, + ), + children: [ + TextSpan( + text: currentEvent + ?.position(currentDate!) + .humanReadableCompact(context), + ), + const TextSpan(text: '\ndebug: '), + TextSpan( + text: tile.videoController.currentPos + .humanReadableCompact(context), + ), + const TextSpan(text: '\nindex: '), + TextSpan( + text: currentEvent == null + ? (-1).toString() + : tile.events + .indexOf(currentEvent!) + .toString(), + ), + const TextSpan(text: '\nscroll: '), + if (this.controller.hasClients) + TextSpan( + text: this + .controller + .position + .pixels + .toString(), + ), + TextSpan( + text: + '\nt: ${tile.videoController.dataSource}'), + ], + ), + ), + ]), + ); + }, + ); + }(), + ), + ), + Center( + child: Container( + margin: const EdgeInsetsDirectional.only( + top: 8.0, + bottom: 14.0, + ), + padding: const EdgeInsetsDirectional.symmetric( + horizontal: 8.0, + vertical: 4.0, + ), + color: theme.colorScheme.secondaryContainer, + child: currentDate == null + ? const Text(' ■■■■■ • ■■■■■ ') + : Text( + '${settings.dateFormat.format(currentDate!)}' + ' ' + '${timelineTimeFormat.format(currentDate!)}', + style: theme.textTheme.labelMedium, + ), + ), + ), + Container( + height: 48.0, + color: theme.colorScheme.secondaryContainer, + child: tile == null + ? Center(child: Text(loc.selectACamera)) + : Stack(children: [ + Positioned.fill( + child: NotificationListener( + onNotification: (Notification notification) { + if (notification is! ScrollNotification) return false; + + if (notification is ScrollStartNotification) { + _onScrollStart(notification); + } else if (notification is ScrollUpdateNotification) { + _onScrollUpdate(notification); + } else if (notification is ScrollEndNotification) { + _onScrollEnd(notification); + return true; + } + + return false; + }, + child: ListView.separated( + controller: controller, + padding: const EdgeInsetsDirectional.symmetric( + horizontal: 8.0, + vertical: 6.0, + ), + separatorBuilder: (_, __) => const SizedBox( + width: _kEventSeparatorWidth, + ), + scrollDirection: Axis.horizontal, + itemCount: tile.events.length, + itemBuilder: (context, index) { + final event = tile.events.elementAt(index); + + return _TimelineTile( + key: ValueKey(event.event.id), + event: event, + index: index, + isCurrentEvent: event == currentEvent, + onPressed: () => setEvent(event), + tile: tile, + ); + }, + ), + ), + ), + Positioned( + left: 8.0, + top: 0, + bottom: 0, + child: Container( + width: 3, + color: theme.colorScheme.onInverseSurface, + ), + ), + ]), + ), + const SizedBox(height: 14.0), + Row(children: [ + Expanded( + child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + IconButton( + icon: const Icon(Icons.fullscreen), + tooltip: loc.showFullscreenCamera, + onPressed: + currentEvent == null ? null : () => enterFullscreen(context), + ), + ]), + ), + IconButton( + icon: const Icon(Icons.skip_previous), + tooltip: loc.previous, + onPressed: lastEventIndex <= 0 + ? null + : () { + setEvent(tile!.events.elementAt(lastEventIndex - 1)); + }, + ), + const SizedBox(width: 6.0), + IconButton.filled( + icon: PlayPauseIcon( + isPlaying: tile?.videoController.isPlaying ?? false, + color: theme.colorScheme.surface, + ), + tooltip: tile == null + ? null + : tile.videoController.isPlaying + ? loc.pause + : loc.play, + iconSize: 32, + onPressed: tile == null + ? null + : () { + if (widget.timeline.isPlaying) { + widget.timeline.stop(); + } else { + widget.timeline.play(currentEvent); + } + setState(() {}); + }, + ), + const SizedBox(width: 6.0), + IconButton( + icon: const Icon(Icons.skip_next), + tooltip: loc.next, + onPressed: lastEventIndex.isNegative || + lastEventIndex == tile!.events.length - 1 + ? null + : () { + setEvent(tile.events.elementAt(lastEventIndex + 1)); + }, + ), + Expanded( + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 12.0), + child: Row(children: [ + // IconButton( + // icon: const Icon(Icons.filter_list), + // tooltip: loc.filter, + // onPressed: lastEventIndex.isNegative + // ? null + // : () => _showFilterSheet(context), + // ), + const Spacer(), + if (tile != null && + (isBuffering || + tile.videoController.currentPos == + tile.videoController.duration || + tile.videoController.currentBuffer == Duration.zero || + widget.timeline.pausedToBuffer.isNotEmpty)) + const SizedBox( + width: 24.0, + height: 24.0, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2.5, + ), + ), + ]), + ), + ), + ]), + const Spacer(), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + if (Scaffold.hasDrawer(context)) + _buildIconButton( + icon: const DrawerButtonIcon(), + text: MaterialLocalizations.of(context).moreButtonTooltip, + onPressed: () => Scaffold.of(context).openDrawer(), + ), + _buildIconButton( + icon: const Icon(Icons.refresh), + text: loc.refresh, + onPressed: () { + eventsPlaybackScreenKey.currentState?.fetch(); + }, + ), + if (tile != null) ...[ + _buildIconButton( + icon: const Icon(Icons.cameraswitch), + text: loc.switchCamera, + onPressed: () { + selectDevice(context); + }, + ), + Builder(builder: (context) { + final downloads = context.watch(); + final event = currentEvent?.event; + + final isDownloaded = + event == null ? false : downloads.isEventDownloaded(event.id); + final isDownloading = event == null + ? false + : downloads.isEventDownloading(event.id); + + return _buildIconButton( + icon: isDownloaded + ? Icon( + Icons.download_done, + color: theme.extension()!.successColor, + ) + : isDownloading + ? DownloadProgressIndicator( + progress: downloads.downloading[downloads + .downloading.keys + .firstWhere((e) => e.id == event.id)]!, + ) + : const Icon(Icons.download), + text: isDownloaded + ? loc.downloaded + : isDownloading + ? loc.downloading + : loc.download, + onPressed: event == null + ? null + : () { + if (isDownloaded || isDownloading) { + context + .read() + .toDownloads(event.id, context); + } else { + downloads.download(event); + } + }, + ); + }), + ], + ]), + ), + ]); + } + + Widget _buildIconButton({ + required Widget icon, + String? text, + VoidCallback? onPressed, + }) { + return Material( + child: InkWell( + borderRadius: BorderRadius.circular(10.0), + onTap: onPressed, + child: Padding( + padding: const EdgeInsetsDirectional.all(8.0), + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + SizedBox( + height: 38.0, + width: 38.0, + child: icon, + ), + if (text != null) Text(text), + ]), + ), + ), + ); + } + + // Future _showFilterSheet(BuildContext context) async { + // await showModalBottomSheet( + // context: context, + // isScrollControlled: true, + // showDragHandle: true, + // builder: (context) { + // return DraggableScrollableSheet( + // maxChildSize: 0.8, + // initialChildSize: 0.7, + // expand: false, + // builder: (context, controller) { + // return ListView( + // controller: controller, + // padding: const EdgeInsetsDirectional.symmetric( + // horizontal: 12.0, + // vertical: 10.0, + // ), + // ); + // }, + // ); + // }, + // ); + // } +} + +class _TimelineTile extends StatelessWidget { + const _TimelineTile({ + super.key, + required this.event, + required this.isCurrentEvent, + required this.tile, + required this.onPressed, + required this.index, + }); + + final TimelineEvent event; + final bool isCurrentEvent; + final TimelineTile tile; + final VoidCallback onPressed; + final int index; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return GestureDetector( + onTap: onPressed, + child: SizedBox( + width: event.duration.inDoubleSeconds, + child: Stack(alignment: AlignmentDirectional.centerStart, children: [ + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.onSecondaryContainer, + borderRadius: BorderRadius.circular(25.0), + ), + ), + ), + if (isCurrentEvent && + tile.videoController.dataSource == event.videoUrl) + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(25.0), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Container( + width: tile.videoController.currentBuffer.inDoubleSeconds, + color: theme.colorScheme.tertiary, + padding: const EdgeInsetsDirectional.symmetric( + horizontal: 12.0, + ), + alignment: AlignmentDirectional.centerStart, + ), + ), + ), + ), + Padding( + padding: const EdgeInsetsDirectional.symmetric( + horizontal: 12.0, + ), + child: Row(children: [ + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.tertiaryContainer, + ), + padding: const EdgeInsetsDirectional.all( + 5.5, + ), + margin: const EdgeInsetsDirectional.only( + end: 4.0, + ), + child: Text( + '${index + 1}', + style: TextStyle( + fontSize: 11.0, + fontWeight: FontWeight.w500, + color: theme.colorScheme.onTertiaryContainer, + ), + ), + ), + Icon( + () { + switch (event.event.type) { + case EventType.motion: + return Icons.directions_run; + case EventType.continuous: + return Icons.horizontal_rule; + default: + return Icons.event_note; + } + }(), + color: theme.colorScheme.surface, + ), + const SizedBox(width: 4.0), + Expanded( + child: Text( + event.event.type.locale(context), + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.surface, + ), + maxLines: 1, + ), + ), + const SizedBox(width: 6.0), + Icon( + Icons.timer, + size: 16.0, + color: theme.colorScheme.surface, + ), + const SizedBox(width: 4.0), + Text( + event.event.duration.humanReadableCompact(context), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.surface, + ), + ), + ]), + ), + ]), + ), + ); + } +} diff --git a/lib/widgets/full_screen_viewer/desktop_viewer.dart b/lib/widgets/full_screen_viewer/desktop_viewer.dart index c4d0aac6..e6b57319 100644 --- a/lib/widgets/full_screen_viewer/desktop_viewer.dart +++ b/lib/widgets/full_screen_viewer/desktop_viewer.dart @@ -50,6 +50,7 @@ class _DeviceFullscreenViewerDesktopState WindowButtons(title: widget.device.fullName, showNavigator: false), Expanded( child: UnityVideoView( + heroTag: widget.device.streamURL, player: widget.videoPlayerController, fit: fit, paneBuilder: (context, controller) { diff --git a/lib/widgets/full_screen_viewer/mobile_viewer.dart b/lib/widgets/full_screen_viewer/mobile_viewer.dart index ae116054..93d13478 100644 --- a/lib/widgets/full_screen_viewer/mobile_viewer.dart +++ b/lib/widgets/full_screen_viewer/mobile_viewer.dart @@ -110,6 +110,7 @@ class _DeviceFullscreenViewerMobileState enabled: !overlay && ptzEnabled, builder: (context, commands) { return UnityVideoView( + heroTag: widget.device.streamURL, player: widget.videoPlayerController, fit: fit, paneBuilder: (context, controller) { diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index 10e676a6..be7022ff 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -25,7 +25,7 @@ import 'package:bluecherry_client/widgets/device_grid/device_grid.dart'; import 'package:bluecherry_client/widgets/direct_camera.dart'; import 'package:bluecherry_client/widgets/downloads_manager.dart'; import 'package:bluecherry_client/widgets/events/events_screen.dart'; -import 'package:bluecherry_client/widgets/events_playback/events_playback.dart'; +import 'package:bluecherry_client/widgets/events_timeline/events_playback.dart'; import 'package:bluecherry_client/widgets/misc.dart'; import 'package:bluecherry_client/widgets/settings/settings.dart'; import 'package:flutter/material.dart'; @@ -153,7 +153,7 @@ class _MobileHomeState extends State { UnityTab.directCameraScreen: () { return const DirectCameraScreen(); }, - UnityTab.eventsPlayback: () => const EventsPlayback(), + UnityTab.eventsPlayback: EventsPlayback.new, UnityTab.eventsScreen: () => EventsScreen( key: eventsScreenKey, ), diff --git a/lib/widgets/misc.dart b/lib/widgets/misc.dart index aeefc0d5..ccd09765 100644 --- a/lib/widgets/misc.dart +++ b/lib/widgets/misc.dart @@ -226,40 +226,42 @@ class SubHeader extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return Container( - height: height, - alignment: AlignmentDirectional.centerStart, - padding: padding, - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - text.toUpperCase(), - style: theme.textTheme.labelSmall?.copyWith( - color: theme.textTheme.displaySmall?.color, - fontSize: 12.0, - fontWeight: FontWeight.w600, - ), - ), - if (subtext != null) + return Material( + child: Container( + height: height, + alignment: AlignmentDirectional.centerStart, + padding: padding, + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - subtext!.toUpperCase(), - style: theme.textTheme.labelSmall - ?.copyWith( - color: theme.hintColor, - fontSize: 10.0, - fontWeight: FontWeight.w600, - ) - .merge(subtextStyle), - ) - ], + text.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.textTheme.displaySmall?.color, + fontSize: 12.0, + fontWeight: FontWeight.w600, + ), + ), + if (subtext != null) + Text( + subtext!.toUpperCase(), + style: theme.textTheme.labelSmall + ?.copyWith( + color: theme.hintColor, + fontSize: 10.0, + fontWeight: FontWeight.w600, + ) + .merge(subtextStyle), + ) + ], + ), ), - ), - if (trailing != null) trailing!, - ]), + if (trailing != null) trailing!, + ]), + ), ); } } @@ -412,3 +414,59 @@ class _PopupLabelState extends State { @override Widget build(BuildContext context) => widget.label; } + +class PlayPauseIcon extends StatefulWidget { + final bool isPlaying; + final Color? color; + final List? shadows; + final double? size; + + const PlayPauseIcon({ + super.key, + required this.isPlaying, + this.color, + this.shadows, + this.size, + }); + + @override + State createState() => _PlayPauseIconState(); +} + +class _PlayPauseIconState extends State + with SingleTickerProviderStateMixin { + late final playPauseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 250), + value: widget.isPlaying ? 1.0 : 0.0, + ); + + @override + void didUpdateWidget(covariant PlayPauseIcon oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isPlaying) { + playPauseController.forward(); + } else { + playPauseController.reverse(); + } + } + + @override + void dispose() { + playPauseController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedIcon( + icon: AnimatedIcons.play_pause, + progress: CurvedAnimation( + curve: Curves.ease, + parent: playPauseController, + ), + color: widget.color, + size: widget.size, + ); + } +} diff --git a/lib/widgets/reorderable_static_grid.dart b/lib/widgets/reorderable_static_grid.dart index 28ee549f..df826edf 100644 --- a/lib/widgets/reorderable_static_grid.dart +++ b/lib/widgets/reorderable_static_grid.dart @@ -32,6 +32,9 @@ class StaticGrid extends StatefulWidget { final int crossAxisCount; final List children; + /// The child to show when the grid is empty + final Widget? emptyChild; + /// The aspect ratio of each child final double childAspectRatio; @@ -51,6 +54,7 @@ class StaticGrid extends StatefulWidget { super.key, required this.crossAxisCount, required this.children, + this.emptyChild, this.childAspectRatio = 1.0, this.mainAxisSpacing = kGridInnerPadding, this.crossAxisSpacing = kGridInnerPadding, @@ -101,6 +105,10 @@ class StaticGridState extends State { @override Widget build(BuildContext context) { + if (widget.children.isEmpty && widget.emptyChild != null) { + return widget.emptyChild!; + } + return Padding( padding: widget.padding.add(EdgeInsetsDirectional.only( start: widget.crossAxisSpacing, diff --git a/lib/widgets/settings/settings.dart b/lib/widgets/settings/settings.dart index 7783704d..da0be640 100644 --- a/lib/widgets/settings/settings.dart +++ b/lib/widgets/settings/settings.dart @@ -240,8 +240,7 @@ class _SettingsState extends State { final selectedDirectory = await FilePicker.platform.getDirectoryPath( dialogTitle: loc.downloadPath, - initialDirectory: - SettingsProvider.instance.downloadsDirectory, + initialDirectory: settings.downloadsDirectory, lockParentWindow: true, ); diff --git a/packages/unity_video_player/unity_video_player_media_kit/lib/unity_video_player_media_kit.dart b/packages/unity_video_player/unity_video_player_media_kit/lib/unity_video_player_media_kit.dart index 29f0835a..ac769b8d 100644 --- a/packages/unity_video_player/unity_video_player_media_kit/lib/unity_video_player_media_kit.dart +++ b/packages/unity_video_player/unity_video_player_media_kit/lib/unity_video_player_media_kit.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:unity_video_player_platform_interface/unity_video_player_platform_interface.dart'; class UnityVideoPlayerMediaKitInterface extends UnityVideoPlayerInterface { @@ -20,8 +21,16 @@ class UnityVideoPlayerMediaKitInterface extends UnityVideoPlayerInterface { } @override - UnityVideoPlayer createPlayer({int? width, int? height}) { - final player = UnityVideoPlayerMediaKit(width: width, height: height); + UnityVideoPlayer createPlayer({ + int? width, + int? height, + bool enableCache = false, + }) { + final player = UnityVideoPlayerMediaKit( + width: width, + height: height, + enableCache: enableCache, + ); UnityVideoPlayerInterface.registerPlayer(player); return player; } @@ -96,7 +105,11 @@ class UnityVideoPlayerMediaKit extends UnityVideoPlayer { late VideoController mkVideoController; late StreamSubscription errorStream; - UnityVideoPlayerMediaKit({int? width, int? height}) { + UnityVideoPlayerMediaKit({ + int? width, + int? height, + bool enableCache = false, + }) { final pixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; if (width != null) width = (width * pixelRatio).toInt(); if (height != null) height = (height * pixelRatio).toInt(); @@ -110,16 +123,25 @@ class UnityVideoPlayerMediaKit extends UnityVideoPlayer { // Check type. Only true for libmpv based platforms. Currently Windows & Linux. if (mkPlayer.platform is libmpvPlayer) { - final platform = (mkPlayer.platform as libmpvPlayer?); - // https://mpv.io/manual/stable/#options-cache - platform?.setProperty("cache", "yes"); - // https://mpv.io/manual/stable/#options-cache-pause-initial - platform?.setProperty("cache-pause-initial", "yes"); - // https://mpv.io/manual/stable/#options-cache-pause-wait - platform?.setProperty("cache-pause-wait", "3"); - - platform?.setProperty("profile", "low-latency"); - // platform?.setProperty("untimed", ""); + final platform = (mkPlayer.platform as libmpvPlayer); + + if (enableCache) { + // https://mpv.io/manual/stable/#options-cache + platform.setProperty("cache", "yes"); + // https://mpv.io/manual/stable/#options-cache-pause-initial + platform.setProperty("cache-pause-initial", "yes"); + // https://mpv.io/manual/stable/#options-cache-pause-wait + platform.setProperty("cache-pause-wait", "1"); + getTemporaryDirectory().then((value) { + platform.setProperty("cache-on-disk", "yes"); + platform.setProperty("cache-dir", value.path); + }); + } else { + platform.setProperty("profile", "low-latency"); + platform.setProperty("cache", "no"); + platform.setProperty("video-sync", "audio"); + platform.setProperty("untimed", ""); + } } errorStream = mkPlayer.streams.error.listen((event) { @@ -184,6 +206,7 @@ class UnityVideoPlayerMediaKit extends UnityVideoPlayer { @override Future setDataSource(String url, {bool autoPlay = true}) { + if (url == dataSource) return Future.value(); return ensureVideoControllerInitialized((controller) async { 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_media_kit/pubspec.yaml b/packages/unity_video_player/unity_video_player_media_kit/pubspec.yaml index ffae7867..b7963eb4 100644 --- a/packages/unity_video_player/unity_video_player_media_kit/pubspec.yaml +++ b/packages/unity_video_player/unity_video_player_media_kit/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: path: media_kit_libs_ios_video/ unity_video_player_platform_interface: path: ../unity_video_player_platform_interface/ + path_provider: dev_dependencies: flutter_test: 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 26a1ceda..b71e92ed 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 @@ -41,7 +41,11 @@ abstract class UnityVideoPlayerInterface extends PlatformInterface { Future initialize(); /// Creates a player - UnityVideoPlayer createPlayer({int? width, int? height}); + UnityVideoPlayer createPlayer({ + int? width, + int? height, + bool enableCache = false, + }); /// Creates a video view Widget createVideoView({ @@ -71,6 +75,7 @@ class UnityVideoView extends StatefulWidget { final UnityVideoPaneBuilder? paneBuilder; final UnityVideoBuilder? videoBuilder; final Color color; + final dynamic heroTag; const UnityVideoView({ super.key, @@ -79,6 +84,7 @@ class UnityVideoView extends StatefulWidget { this.paneBuilder, this.videoBuilder, this.color = const Color(0xFF000000), + this.heroTag, }); static UnityVideoViewState of(BuildContext context) { @@ -137,13 +143,22 @@ class UnityVideoViewState extends State { @override Widget build(BuildContext context) { - return UnityVideoPlayerInterface.instance.createVideoView( + final videoView = UnityVideoPlayerInterface.instance.createVideoView( player: widget.player, color: widget.color, fit: widget.fit, videoBuilder: widget.videoBuilder, paneBuilder: widget.paneBuilder, ); + + if (widget.heroTag != null) { + return Hero( + tag: widget.heroTag, + child: videoView, + ); + } + + return videoView; } } @@ -190,10 +205,12 @@ enum UnityVideoQuality { abstract class UnityVideoPlayer { static UnityVideoPlayer create({ UnityVideoQuality quality = UnityVideoQuality.p360, + bool enableCache = false, }) { return UnityVideoPlayerInterface.instance.createPlayer( width: quality.resolution.width.toInt(), height: quality.resolution.height.toInt(), + enableCache: enableCache, )..quality = quality; } diff --git a/pubspec.lock b/pubspec.lock index 8d248ded..4c22e1b8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -675,6 +675,14 @@ packages: description: flutter source: sdk version: "0.0.99" + sliver_tools: + dependency: "direct main" + description: + name: sliver_tools + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 + url: "https://pub.dev" + source: hosted + version: "0.2.12" source_span: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a8237b1d..6158c828 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: provider: ^6.0.5 reorderables: ^0.6.0 flutter_simple_treeview: ^3.0.2 + sliver_tools: ^0.2.12 intl: ^0.18.0 duration: ^3.0.12