Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/api/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ extension EventsExtension on API {
eventObject['content']['content'] as String,
)
: null,
mediaDuration:
eventObject.containsKey('content')
? Event.tryParseMediaDuration(
eventObject['content']['media_duration'],
)
: null,
);
}).toList();
} else {
Expand Down
39 changes: 36 additions & 3 deletions lib/models/event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class Event {
final int? mediaID;
final Uri? mediaURL;

/// The recording length reported by the server (`media_duration`, in
/// seconds), or `null` when the server did not provide one.
final Duration? mediaDuration;

const Event({
required this.server,
required this.id,
Expand All @@ -51,6 +55,7 @@ class Event {
required this.category,
required this.mediaID,
required this.mediaURL,
this.mediaDuration,
});

Event.dump({
Expand All @@ -65,6 +70,7 @@ class Event {
this.category,
this.mediaID,
this.mediaURL,
this.mediaDuration,
}) : server =
server ??
ServersProvider.instance.servers.elementAtOrNull(0) ??
Expand All @@ -85,11 +91,32 @@ class Event {
}

Duration get duration {
// The server-provided media duration is the actual recording length,
// while updated - published is only an approximation. A zero value
// means the server does not know the length, so keep the approximation
// in that case.
final media = mediaDuration;
if (media != null && media > Duration.zero) return media;

final dur = updated.difference(published);
if (dur < Duration.zero) return published.difference(updated);
return dur;
}

/// Parses a server `media_duration` value (whole seconds) into a [Duration].
///
/// Returns `null` when the value is missing or not a plausible whole
/// number of seconds.
static Duration? tryParseMediaDuration(dynamic value) {
final seconds = switch (value) {
final int s => s,
final String s => int.tryParse(s),
_ => null,
};
if (seconds == null || seconds < 0) return null;
return Duration(seconds: seconds);
}

@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
Expand All @@ -105,7 +132,8 @@ class Event {
other.updated == updated &&
other.category == category &&
other.mediaID == mediaID &&
other.mediaURL == mediaURL;
other.mediaURL == mediaURL &&
other.mediaDuration == mediaDuration;
}

@override
Expand All @@ -120,12 +148,13 @@ class Event {
updated.hashCode ^
category.hashCode ^
mediaID.hashCode ^
mediaURL.hashCode;
mediaURL.hashCode ^
mediaDuration.hashCode;
}

@override
String toString() {
return 'Event(server: ${server.ip}, id: $id, deviceID: $deviceID, title: $title, publishedRaw: $publishedRaw, published: $published, updatedRaw: $updatedRaw, updated: $updated, category: $category, mediaID: $mediaID, mediaURL: $mediaURL)';
return 'Event(server: ${server.ip}, id: $id, deviceID: $deviceID, title: $title, publishedRaw: $publishedRaw, published: $published, updatedRaw: $updatedRaw, updated: $updated, category: $category, mediaID: $mediaID, mediaURL: $mediaURL, mediaDuration: $mediaDuration)';
}

Map<String, dynamic> toJson() => {
Expand All @@ -138,6 +167,7 @@ class Event {
'category': category,
'mediaID': mediaID,
'mediaURL': mediaURL.toString(),
'mediaDuration': mediaDuration?.inSeconds,
};

factory Event.fromJson(Map<String, dynamic> json) {
Expand All @@ -153,6 +183,7 @@ class Event {
category: json['category'],
mediaID: json['mediaID'],
mediaURL: Uri.parse(json['mediaURL']),
mediaDuration: tryParseMediaDuration(json['mediaDuration']),
);
}

Expand Down Expand Up @@ -231,6 +262,7 @@ class Event {
String? category,
int? mediaID,
Uri? mediaURL,
Duration? mediaDuration,
}) {
return Event(
server: server ?? this.server,
Expand All @@ -244,6 +276,7 @@ class Event {
category: category ?? this.category,
mediaID: mediaID ?? this.mediaID,
mediaURL: mediaURL ?? this.mediaURL,
mediaDuration: mediaDuration ?? this.mediaDuration,
);
}
}
Expand Down
15 changes: 15 additions & 0 deletions lib/screens/events_timeline/desktop/timeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import 'package:bluecherry_client/models/event.dart';
import 'package:bluecherry_client/providers/events_provider.dart';
import 'package:bluecherry_client/providers/settings_provider.dart';
import 'package:bluecherry_client/utils/date.dart';
import 'package:bluecherry_client/utils/debouncer.dart';
import 'package:bluecherry_client/utils/extensions.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
Expand Down Expand Up @@ -369,6 +370,10 @@ class Timeline extends ChangeNotifier {

DateTime get currentDate => date.add(currentPosition);

/// Coalesces the expensive player re-opens triggered by [seekTo] while
/// scrubbing, so rapid movements only re-open once the position settles.
final _seekDebouncer = Debouncer(const Duration(milliseconds: 350));

void seekTo(Duration position) {
if (position < Duration.zero) {
currentPosition = Duration.zero;
Expand All @@ -379,6 +384,15 @@ class Timeline extends ChangeNotifier {
}
notifyListeners();

// Re-opening the tile players is expensive: each jumpToIndex disposes and
// re-creates the underlying player. Wait until scrubbing settles instead
// of re-opening on every movement. currentDate is read when the debounced
// action runs, so it always uses the latest position.
_seekDebouncer.run(_applySeekToPlayers);
}

/// Re-opens the tile players at [currentDate], after scrubbing settles.
void _applySeekToPlayers() {
forEachEvent((tile, event) async {
if (!event.isPlaying(currentDate)) return;
final eventIndex = tile.events.indexOf(event);
Expand Down Expand Up @@ -626,6 +640,7 @@ class Timeline extends ChangeNotifier {

@override
void dispose() {
_seekDebouncer.cancel();
stop();
for (final tile in tiles) {
tile.videoController.dispose();
Expand Down
46 changes: 46 additions & 0 deletions lib/utils/debouncer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/

import 'dart:async';

/// Coalesces rapid calls into a single execution.
///
/// Each call to [run] cancels the pending action, if any, and schedules
/// [action] to run after [duration] of inactivity. Only the latest action
/// runs.
class Debouncer {
final Duration duration;

Timer? _timer;

Debouncer(this.duration);

/// Whether an action is currently scheduled.
bool get isPending => _timer?.isActive ?? false;

void run(void Function() action) {
_timer?.cancel();
_timer = Timer(duration, action);
}

void cancel() {
_timer?.cancel();
_timer = null;
}
}
5 changes: 5 additions & 0 deletions lib/utils/video_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,11 @@ class UnityPlayers with ChangeNotifier {
for (final device in devices) {
if (players.containsKey(device.uuid)) continue;
players[device.uuid] = forDevice(device);
// Players are created progressively (see the delay below) and some
// callers do not await this method, so notify as each player becomes
// available. Otherwise tiles built before their player exists never
// rebuild and stay empty until a manual reload.
instance.notifyListeners();
await Future.delayed(const Duration(milliseconds: 350));
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,34 @@ class UnityVideoPlayerFlutter extends UnityVideoPlayer {
Stream<bool> get onPlayingStateUpdate =>
_videoStream.stream.map((value) => value.isPlaying);

/// Serializes [setDataSource] calls.
///
/// Opening a source disposes and re-creates the underlying controller,
/// which must not overlap: a new call starting while the previous one is
/// still initializing would dispose a controller mid-initialization and
/// then operate on the wrong controller. Each call waits for the previous
/// one to finish first.
Future<void> _setDataSourceChain = Future.value();

@override
Future<void> setDataSource(
String url, {
bool autoPlay = true,
Map<String, String>? headers,
}) {
final pending = _setDataSourceChain.then(
(_) => _setDataSource(url, autoPlay: autoPlay, headers: headers),
);
// Keep the chain usable when an operation fails. Errors still propagate
// to this caller through [pending].
_setDataSourceChain = pending.then((_) {}, onError: (_) {});
return pending;
}

Future<void> _setDataSource(
String url, {
bool autoPlay = true,
Map<String, String>? headers,
}) async {
if (url == dataSource) return Future.value();

Expand Down Expand Up @@ -263,7 +286,10 @@ class UnityVideoPlayerFlutter extends UnityVideoPlayer {

@override
double get fps {
if (!isPi || player == null || kIsWeb) return 0.0;
// Media info (and its frame rate) is only available through the fvp
// backend on desktop and mobile. The Pi uses a different backend where
// the fvp extension throws, and web has no media info at all.
if (isPi || player == null || kIsWeb) return 0.0;

return (player?.getMediaInfo() as dynamic) // Make it web safe
?.video
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ abstract class UnityVideoPlayer with ChangeNotifier {

UnityVideoPlayer({this.width, this.height}) {
onReady = () {
if (_isReady) {
// Implementations call this every time a new source is opened. Drop
// the previous subscriptions first, so repeated re-opens do not
// accumulate duplicate listeners.
_onErrorSubscription.cancel();
_onDurationUpdateSubscription.cancel();
_onPositionUpdateSubscription.cancel();
_fpsSubscription.cancel();
}
_isReady = true;
_onErrorSubscription = onError.listen(_onError);
_onDurationUpdateSubscription =
Expand Down
2 changes: 1 addition & 1 deletion pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ packages:
source: hosted
version: "4.0.3"
fake_async:
dependency: transitive
dependency: "direct dev"
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
Expand Down
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ dependencies:
dev_dependencies:
flutter_test:
sdk: flutter
fake_async: ^1.3.3
flutter_lints: ^5.0.0
msix: ^3.7.0
flutter_launcher_icons: ^0.14.3
Expand Down
76 changes: 76 additions & 0 deletions test/debouncer_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import 'package:bluecherry_client/utils/debouncer.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('Debouncer', () {
test('runs the action after the duration', () {
var calls = 0;
final debouncer = Debouncer(const Duration(milliseconds: 300));

FakeAsync().run((async) {
debouncer.run(() => calls++);
expect(calls, 0);

async.elapse(const Duration(milliseconds: 300));
expect(calls, 1);
});
});

test('coalesces rapid calls into one', () {
var calls = 0;
final debouncer = Debouncer(const Duration(milliseconds: 300));

FakeAsync().run((async) {
for (var i = 0; i < 10; i++) {
debouncer.run(() => calls++);
async.elapse(const Duration(milliseconds: 100));
}

async.elapse(const Duration(milliseconds: 300));
expect(calls, 1);
});
});

test('runs the latest action', () {
var value = 0;
final debouncer = Debouncer(const Duration(milliseconds: 300));

FakeAsync().run((async) {
debouncer.run(() => value = 1);
debouncer.run(() => value = 2);

async.elapse(const Duration(milliseconds: 300));
expect(value, 2);
});
});

test('cancel prevents a pending action', () {
var calls = 0;
final debouncer = Debouncer(const Duration(milliseconds: 300));

FakeAsync().run((async) {
debouncer.run(() => calls++);
debouncer.cancel();

async.elapse(const Duration(milliseconds: 600));
expect(calls, 0);
});
});

test('can be reused after firing', () {
var calls = 0;
final debouncer = Debouncer(const Duration(milliseconds: 300));

FakeAsync().run((async) {
debouncer.run(() => calls++);
async.elapse(const Duration(milliseconds: 300));
expect(calls, 1);

debouncer.run(() => calls++);
async.elapse(const Duration(milliseconds: 300));
expect(calls, 2);
});
});
});
}
Loading
Loading