diff --git a/lib/api/api.dart b/lib/api/api.dart index 46404c32..9476eece 100644 --- a/lib/api/api.dart +++ b/lib/api/api.dart @@ -20,15 +20,15 @@ import 'dart:convert'; import 'package:bluecherry_client/api/api_helpers.dart'; -import 'package:bluecherry_client/api/ptz.dart'; import 'package:bluecherry_client/models/device.dart'; -import 'package:bluecherry_client/models/event.dart'; import 'package:bluecherry_client/models/server.dart'; -import 'package:bluecherry_client/utils/constants.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart'; import 'package:xml2json/xml2json.dart'; +export 'events.dart'; +export 'ptz.dart'; + class API { static final API instance = API(); @@ -138,90 +138,6 @@ class API { return null; } - /// Gets the [Event]s present on the [server]. - /// - /// If server is offline, then it returns an empty list. - Future> getEvents(Server server) async { - if (!server.online) { - debugPrint('Can not get events of an offline server: $server'); - return []; - } - - return compute(_getEvents, server); - } - - static Future> _getEvents(Server server) async { - if (!server.online) { - debugPrint('Can not get events of an offline server: $server'); - return []; - } - - DevHttpOverrides.configureCertificates(); - - assert(server.serverUUID != null && server.cookie != null); - final response = await get( - Uri.https( - '${Uri.encodeComponent(server.login)}:${Uri.encodeComponent(server.password)}@${server.ip}:${server.port}', - '/events/', - { - 'XML': '1', - 'limit': '$kEventsLimit', - }, - ), - headers: { - 'Cookie': server.cookie!, - }, - ); - - try { - debugPrint('Getting events for server ${server.name}'); - // debugPrint(response.body); - - final parser = Xml2Json()..parse(response.body); - 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; - } on Xml2JsonException { - debugPrint('Failed to parse XML response from server $server'); - debugPrint(response.body); - } catch (exception, stacktrace) { - debugPrint('Failed to getEvents on server $server'); - debugPrint(exception.toString()); - debugPrint(stacktrace.toString()); - } - return []; - } - /// Returns the notification API endpoint. /// /// Returns the endpoint as [String] if the request was successful, otherwise returns `null`. @@ -323,109 +239,4 @@ class API { return false; } } - - /// * - Future ptz({ - required Device device, - required Movement movement, - PTZCommand command = PTZCommand.move, - int panSpeed = 1, - int tiltSpeed = 1, - int duration = 250, - }) async { - if (!device.hasPTZ) return false; - - final server = device.server; - - final url = Uri.https( - '${Uri.encodeComponent(server.login)}:${Uri.encodeComponent(server.password)}@${server.ip}:${server.port}', - '/media/ptz.php', - { - 'id': '${device.id}', - 'command': command.name, - - // commands - if (movement == Movement.moveNorth) - 'tilt': 'u' //up - else if (movement == Movement.moveSouth) - 'tilt': 'd' //down - else if (movement == Movement.moveWest) - 'pan': 'l' //left - else if (movement == Movement.moveEast) - 'pan': 'r' //right - else if (movement == Movement.moveWide) - 'zoom': 'w' //wide - else if (movement == Movement.moveTele) - 'zoom': 't', //tight - - // speeds - if (command == PTZCommand.move) ...{ - if (panSpeed > 0) 'panspeed': '$panSpeed', - if (tiltSpeed > 0) 'tiltspeed': '$tiltSpeed', - if (duration >= -1) 'duration': '$duration', - }, - }, - ); - - debugPrint(url.toString()); - - final response = await get( - url, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Cookie': server.cookie!, - }, - ); - - debugPrint('${command.name} ${response.statusCode}'); - - if (response.statusCode == 200) { - return true; - } - - return false; - } - - /// * - Future presets({ - required Device device, - required PresetCommand command, - String? presetId, - String? presetName, - }) async { - if (!device.hasPTZ) return false; - - final server = device.server; - - assert(presetName != null || command != PresetCommand.save); - - final url = Uri.https( - '${Uri.encodeComponent(server.login)}:${Uri.encodeComponent(server.password)}@${server.ip}:${server.port}', - '/media/ptz.php', - { - 'id': '${device.id}', - 'command': command.name, - if (presetId != null) 'preset': presetId, - if (presetName != null) 'name': presetName, - }, - ); - - debugPrint(url.toString()); - - final response = await get( - url, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Cookie': server.cookie!, - }, - ); - - debugPrint('${command.name} ${response.body} ${response.statusCode}'); - - if (response.statusCode == 200) { - return true; - } - - return false; - } } diff --git a/lib/api/events.dart b/lib/api/events.dart new file mode 100644 index 00000000..94dc41b9 --- /dev/null +++ b/lib/api/events.dart @@ -0,0 +1,142 @@ +import 'dart:convert'; + +import 'package:bluecherry_client/api/api.dart'; +import 'package:bluecherry_client/api/api_helpers.dart'; +import 'package:bluecherry_client/models/event.dart'; +import 'package:bluecherry_client/models/server.dart'; +import 'package:bluecherry_client/utils/constants.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:xml2json/xml2json.dart'; + +extension EventsExtension on API { + /// Gets the [Event]s present on the [server]. + /// + /// If server is offline, then it returns an empty list. + Future> getEvents(Server server) async { + if (!server.online) { + debugPrint('Can not get events of an offline server: $server'); + return []; + } + + return compute(_getEvents, server); + } + + static Future> _getEvents(Server server) async { + if (!server.online) { + debugPrint('Can not get events of an offline server: $server'); + return []; + } + + DevHttpOverrides.configureCertificates(); + + assert(server.serverUUID != null && server.cookie != null); + final response = await http.get( + Uri.https( + '${Uri.encodeComponent(server.login)}:${Uri.encodeComponent(server.password)}@${server.ip}:${server.port}', + '/events/', + { + 'XML': '1', + 'limit': '$kEventsLimit', + }, + ), + headers: { + 'Cookie': server.cookie!, + }, + ); + + Iterable events = []; + + try { + debugPrint('Getting events for server ${server.name}'); + + final parser = Xml2Json()..parse(response.body); + 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(); + } on Xml2JsonException { + debugPrint('Failed to parse XML response from server ${server.name}'); + debugPrint('Attempting to parse using JSON'); + + events = ((jsonDecode(response.body) as Map)['entry'] as List) + .cast() + .map((eventObject) { + final published = DateTime.parse(eventObject['published']).toLocal(); + final event = Event.factory( + server: server, + id: () { + final idObject = eventObject['id'].toString(); + + if (int.tryParse(idObject) != null) { + return int.parse(idObject); + } + + final parts = idObject.split('id='); + if (parts.isEmpty) { + return -1; + } + + return int.parse(parts.last); + }(), + deviceID: int.parse( + (eventObject['category']['term'] as String?)?.split('/').first ?? + '-1', + ), + title: eventObject['title'], + published: published, + updated: eventObject['updated'] == null + ? published + : DateTime.parse(eventObject['updated']).toLocal(), + category: eventObject['category']['term'], + mediaID: eventObject.containsKey('content') + ? int.parse(eventObject['content']['media_id']) + : null, + mediaURL: eventObject.containsKey('content') + ? Uri.parse( + (eventObject['content']['content'] as String).replaceAll( + 'https://', + 'https://${Uri.encodeComponent(server.login)}:${Uri.encodeComponent(server.password)}@', + ), + ) + : null, + ); + return event; + }); + } catch (exception, stacktrace) { + debugPrint('Failed to getEvents on server ${server.name}'); + debugPrint(exception.toString()); + debugPrint(stacktrace.toString()); + } + + debugPrint('Loaded ${events.length} events for server ${server.name}'); + + return events; + } +} diff --git a/lib/api/ptz.dart b/lib/api/ptz.dart index 43dbcb4f..bf28ead1 100644 --- a/lib/api/ptz.dart +++ b/lib/api/ptz.dart @@ -17,8 +17,11 @@ * along with this program. If not, see . */ +import 'package:bluecherry_client/api/api.dart'; +import 'package:bluecherry_client/models/device.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:http/http.dart' as http; enum PTZCommand { move, @@ -74,3 +77,110 @@ enum PresetCommand { go, clear; } + +extension PtzApiExtension on API { + /// * + Future ptz({ + required Device device, + required Movement movement, + PTZCommand command = PTZCommand.move, + int panSpeed = 1, + int tiltSpeed = 1, + int duration = 250, + }) async { + if (!device.hasPTZ) return false; + + final server = device.server; + + final url = Uri.https( + '${Uri.encodeComponent(server.login)}:${Uri.encodeComponent(server.password)}@${server.ip}:${server.port}', + '/media/ptz.php', + { + 'id': '${device.id}', + 'command': command.name, + + // commands + if (movement == Movement.moveNorth) + 'tilt': 'u' //up + else if (movement == Movement.moveSouth) + 'tilt': 'd' //down + else if (movement == Movement.moveWest) + 'pan': 'l' //left + else if (movement == Movement.moveEast) + 'pan': 'r' //right + else if (movement == Movement.moveWide) + 'zoom': 'w' //wide + else if (movement == Movement.moveTele) + 'zoom': 't', //tight + + // speeds + if (command == PTZCommand.move) ...{ + if (panSpeed > 0) 'panspeed': '$panSpeed', + if (tiltSpeed > 0) 'tiltspeed': '$tiltSpeed', + if (duration >= -1) 'duration': '$duration', + }, + }, + ); + + debugPrint(url.toString()); + + final response = await http.get( + url, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cookie': server.cookie!, + }, + ); + + debugPrint('${command.name} ${response.statusCode}'); + + if (response.statusCode == 200) { + return true; + } + + return false; + } + + /// * + Future presets({ + required Device device, + required PresetCommand command, + String? presetId, + String? presetName, + }) async { + if (!device.hasPTZ) return false; + + final server = device.server; + + assert(presetName != null || command != PresetCommand.save); + + final url = Uri.https( + '${Uri.encodeComponent(server.login)}:${Uri.encodeComponent(server.password)}@${server.ip}:${server.port}', + '/media/ptz.php', + { + 'id': '${device.id}', + 'command': command.name, + if (presetId != null) 'preset': presetId, + if (presetName != null) 'name': presetName, + }, + ); + + debugPrint(url.toString()); + + final response = await http.get( + url, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cookie': server.cookie!, + }, + ); + + debugPrint('${command.name} ${response.body} ${response.statusCode}'); + + if (response.statusCode == 200) { + return true; + } + + return false; + } +} diff --git a/lib/models/event.dart b/lib/models/event.dart index b4f957c7..d622285c 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -47,6 +47,18 @@ class Event { this.mediaURL, ); + const Event.factory({ + required this.server, + required this.id, + required this.deviceID, + required this.title, + required this.published, + required this.updated, + required this.category, + required this.mediaID, + required this.mediaURL, + }); + Event.dump({ Server? server, this.id = 1, diff --git a/lib/widgets/events_timeline/desktop/timeline_sidebar.dart b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart index 3f8baeb3..b1475e2b 100644 --- a/lib/widgets/events_timeline/desktop/timeline_sidebar.dart +++ b/lib/widgets/events_timeline/desktop/timeline_sidebar.dart @@ -146,7 +146,7 @@ class _TimelineSidebarState extends State { widget.timeline.add([ state.realDevices.entries .firstWhere((e) => e.key == device) - .buildTimelineTile(), + .buildTimelineTile(context), ]); } } @@ -202,7 +202,7 @@ class _TimelineSidebarState extends State { widget.timeline.add([ state.realDevices.entries .firstWhere((e) => e.key == device) - .buildTimelineTile(), + .buildTimelineTile(context), ]); } setState(() {}); diff --git a/lib/widgets/events_timeline/events_playback.dart b/lib/widgets/events_timeline/events_playback.dart index d2addd49..42414f66 100644 --- a/lib/widgets/events_timeline/events_playback.dart +++ b/lib/widgets/events_timeline/events_playback.dart @@ -17,9 +17,12 @@ * along with this program. If not, see . */ +import 'dart:io'; + 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/downloads_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'; @@ -172,7 +175,7 @@ class _EventsPlaybackState extends State { final parsedTiles = devices.entries .where((e) => e.value.isNotEmpty) - .map((e) => e.buildTimelineTile()); + .map((e) => e.buildTimelineTile(context)); home.notLoading(UnityLoadingReason.fetchingEventsPlayback); @@ -253,7 +256,7 @@ class _EventsPlaybackState extends State { } extension DevicesMapExtension on MapEntry> { - TimelineTile buildTimelineTile() { + TimelineTile buildTimelineTile(BuildContext context) { final device = key; final events = value; debugPrint('Loaded ${events.length} events for $device'); @@ -261,10 +264,18 @@ extension DevicesMapExtension on MapEntry> { return TimelineTile( device: device, events: events.map((event) { + final downloads = context.read(); + final mediaUrl = downloads.isEventDownloaded(event.id) + ? Uri.file( + downloads.getDownloadedPathForEvent(event.id), + windows: Platform.isWindows, + ).toString() + : event.mediaURL!.toString(); + return TimelineEvent( startTime: event.published, duration: event.duration, - videoUrl: event.mediaURL!.toString(), + videoUrl: mediaUrl, 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 index 1a157018..d5d9b2e9 100644 --- a/lib/widgets/events_timeline/mobile/timeline_device_view.dart +++ b/lib/widgets/events_timeline/mobile/timeline_device_view.dart @@ -316,7 +316,7 @@ class _TimelineDeviceViewState extends State { } return UnityVideoView( - heroTag: currentEvent!.event.mediaURL, + heroTag: currentEvent!.videoUrl, player: tile.videoController, paneBuilder: !kDebugMode ? null diff --git a/lib/widgets/ptz.dart b/lib/widgets/ptz.dart index 437c7fbb..c86d6ec3 100644 --- a/lib/widgets/ptz.dart +++ b/lib/widgets/ptz.dart @@ -18,7 +18,6 @@ */ import 'package:bluecherry_client/api/api.dart'; -import 'package:bluecherry_client/api/ptz.dart'; import 'package:bluecherry_client/models/device.dart'; import 'package:bluecherry_client/widgets/hover_button.dart'; import 'package:flutter/material.dart';