Skip to content
Open
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
4 changes: 0 additions & 4 deletions lib/bootstrap/platform/desktop_platform_wrapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:macos_window_utils/window_manipulator.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:smtc_windows/smtc_windows.dart' if (dart.library.html) 'package:fladder/stubs/web/smtc_web.dart';
import 'package:window_manager/window_manager.dart';

import 'package:fladder/bootstrap/platform/base_app_wrapper.dart';
Expand All @@ -27,9 +26,6 @@ class DesktopAppWrapper extends BaseAppWrapper {
class _DesktopAppWrapperState extends BaseAppWrapperState<DesktopAppWrapper> with WindowListener {
@override
Future<void> platformInit() async {
if (defaultTargetPlatform == TargetPlatform.windows) {
await SMTCWindows.initialize();
}
if (defaultTargetPlatform == TargetPlatform.macOS) {
await WindowManipulator.initialize(enableWindowDelegate: true);
}
Expand Down
32 changes: 28 additions & 4 deletions lib/providers/video_player_provider.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

import 'package:flutter_riverpod/flutter_riverpod.dart';
Expand All @@ -20,29 +21,52 @@ final playBackModel = StateProvider<PlaybackModel?>((ref) => null);

final videoPlayerProvider = StateNotifierProvider<VideoPlayerNotifier, MediaControlsWrapper>((ref) {
final videoPlayer = VideoPlayerNotifier(ref);
videoPlayer.init();
if (defaultTargetPlatform != TargetPlatform.windows) {
unawaited(videoPlayer.init());
}
return videoPlayer;
});

typedef VideoPlayerInitializer = Future<void> Function();

class VideoPlayerNotifier extends StateNotifier<MediaControlsWrapper> {
VideoPlayerNotifier(this.ref) : super(MediaControlsWrapper(ref: ref));
VideoPlayerNotifier(this.ref, {VideoPlayerInitializer? initializer})
: _initializerOverride = initializer,
super(MediaControlsWrapper(ref: ref));

final Ref ref;
final VideoPlayerInitializer? _initializerOverride;
Future<void>? _activeInitialization;

List<StreamSubscription> subscriptions = [];

late final mediaState = ref.read(mediaPlaybackProvider.notifier);

MediaPlaybackModel get playbackState => ref.read(mediaPlaybackProvider);

Future<void> init() async {
Future<void> init() {
final activeInitialization = _activeInitialization;
if (activeInitialization != null) return activeInitialization;

late final Future<void> operation;
operation = Future<void>.sync(_initializerOverride ?? _initializePlayer).whenComplete(() {
if (identical(_activeInitialization, operation)) {
_activeInitialization = null;
}
});
_activeInitialization = operation;
return operation;
}

Future<void> _initializePlayer() async {
await state.stop();
await state.dispose();
await state.init();

for (final s in subscriptions) {
s.cancel();
await s.cancel();
}
subscriptions.clear();

final subscription = state.stateStream.listen((value) {
updateBuffering(value.buffering);
Expand Down
28 changes: 28 additions & 0 deletions lib/util/single_flight_initializer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class SingleFlightInitializer<T> {
Future<T>? _activeInitialization;
T? _value;
bool _hasValue = false;

bool get isRunning => _activeInitialization != null;
bool get hasValue => _hasValue;

Future<T> run(Future<T> Function() initialize) {
if (_hasValue) return Future<T>.value(_value as T);

final activeInitialization = _activeInitialization;
if (activeInitialization != null) return activeInitialization;

late final Future<T> operation;
operation = Future<T>.sync(initialize).then((value) {
_value = value;
_hasValue = true;
return value;
}).whenComplete(() {
if (identical(_activeInitialization, operation)) {
_activeInitialization = null;
}
});
_activeInitialization = operation;
return operation;
}
}
54 changes: 36 additions & 18 deletions lib/wrappers/media_control_wrapper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import 'package:fladder/providers/video_player_provider.dart';
import 'package:fladder/providers/window_title_provider.dart';
import 'package:fladder/src/video_player_helper.g.dart' hide PlaybackState;
import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/single_flight_initializer.dart';
import 'package:fladder/wrappers/players/base_player.dart';
import 'package:fladder/wrappers/players/lib_mdk.dart'
if (dart.library.html) 'package:fladder/stubs/web/lib_mdk_web.dart';
Expand Down Expand Up @@ -68,6 +69,7 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro
List<StreamSubscription> subscriptions = [];
ProviderSubscription? _subtitleSettingsSubscription;
SMTCWindows? smtc;
final SingleFlightInitializer<SMTCWindows> _smtcInitializer = SingleFlightInitializer();

bool initializedWrapper = false;
bool _isStopped = false;
Expand All @@ -88,7 +90,6 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro

Future<void> init() async {
if (!initializedWrapper) {
initializedWrapper = true;
if (!kIsWeb && Platform.isAndroid) {
VideoPlayerControlsCallback.setUp(this);
}
Expand All @@ -106,6 +107,7 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro
androidShowNotificationBadge: true,
),
);
initializedWrapper = true;
}

final player = switch (ref.read(videoPlayerSettingsProvider).wantedPlayer) {
Expand All @@ -114,7 +116,7 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro
PlayerOptions.nativePlayer => NativePlayer(),
};

setup(player);
await setup(player);
}

Future<void> dispose() async {
Expand All @@ -131,16 +133,17 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro

_player = newPlayer;
await newPlayer.init(ref.read(videoPlayerSettingsProvider));
_initPlayer();
await _initPlayer();
_subscribePlayerState();
}

void _initPlayer() {
Future<void> _initPlayer() async {
_subtitleSettingsSubscription?.close();
for (var element in subscriptions) {
element.cancel();
await element.cancel();
}
_subscribePlayer();
subscriptions.clear();
await _subscribePlayer();
_subtitleSettingsSubscription = ref.listen(subtitleSettingsProvider, (_, next) {
_player?.applySubtitleSettings(next);
});
Expand Down Expand Up @@ -205,19 +208,17 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro
}
}

void _subscribePlayer() {
Future<void> _subscribePlayer() async {
if (!kIsWeb && Platform.isWindows) {
smtc = SMTCWindows(
config: const SMTCConfig(
fastForwardEnabled: true,
nextEnabled: false,
pauseEnabled: true,
playEnabled: true,
rewindEnabled: true,
prevEnabled: false,
stopEnabled: true,
),
);
if (ref.read(clientSettingsProvider).enableMediaKeys) {
try {
smtc = await _ensureSmtcInitialized();
} catch (error, stackTrace) {
log('Failed to initialize Windows media controls: $error\n$stackTrace');
}
} else {
await smtc?.disableSmtc();
}

if (smtc != null) {
subscriptions.add(
Expand Down Expand Up @@ -272,6 +273,23 @@ class MediaControlsWrapper extends BaseAudioHandler implements VideoPlayerContro
}));
}

Future<SMTCWindows> _ensureSmtcInitialized() {
return _smtcInitializer.run(() async {
await SMTCWindows.initialize();
return SMTCWindows(
config: const SMTCConfig(
fastForwardEnabled: true,
nextEnabled: false,
pauseEnabled: true,
playEnabled: true,
rewindEnabled: true,
prevEnabled: false,
stopEnabled: true,
),
);
});
}

@override
Future<void> skipToNext() async {
if (_isAudioQueueMode) {
Expand Down
52 changes: 52 additions & 0 deletions test/single_flight_initializer_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import 'dart:async';

import 'package:flutter_test/flutter_test.dart';

import 'package:fladder/util/single_flight_initializer.dart';

void main() {
test('concurrent calls share one initialization', () async {
final initializer = SingleFlightInitializer<int>();
final completer = Completer<int>();
var calls = 0;

Future<int> initialize() {
calls++;
return completer.future;
}

final first = initializer.run(initialize);
final second = initializer.run(initialize);

expect(identical(first, second), isTrue);
expect(calls, 1);
expect(initializer.isRunning, isTrue);

completer.complete(42);

await expectLater(first, completion(42));
expect(initializer.isRunning, isFalse);
expect(initializer.hasValue, isTrue);
await expectLater(initializer.run(initialize), completion(42));
expect(calls, 1);
});

test('failed initialization can be retried', () async {
final initializer = SingleFlightInitializer<int>();
var calls = 0;

Future<int> initialize() async {
calls++;
if (calls == 1) throw StateError('first attempt failed');
return 7;
}

await expectLater(initializer.run(initialize), throwsStateError);
expect(initializer.isRunning, isFalse);
expect(initializer.hasValue, isFalse);

await expectLater(initializer.run(initialize), completion(7));
expect(calls, 2);
expect(initializer.hasValue, isTrue);
});
}
66 changes: 66 additions & 0 deletions test/video_player_initialization_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import 'dart:async';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:fladder/providers/video_player_provider.dart';

void main() {
test('concurrent player initialization requests share one operation', () async {
final completer = Completer<void>();
var calls = 0;
final provider = Provider<VideoPlayerNotifier>((ref) {
return VideoPlayerNotifier(ref, initializer: () {
calls++;
return completer.future;
});
});
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(provider);

final first = notifier.init();
final second = notifier.init();

expect(identical(first, second), isTrue);
expect(calls, 1);

completer.complete();
await Future.wait([first, second]);
});

test('failed player initialization can be retried', () async {
var calls = 0;
final provider = Provider<VideoPlayerNotifier>((ref) {
return VideoPlayerNotifier(ref, initializer: () async {
calls++;
if (calls == 1) throw StateError('first attempt failed');
});
});
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(provider);

await expectLater(notifier.init(), throwsStateError);
await notifier.init();

expect(calls, 2);
});

test('completed player initialization can be run again for backend changes', () async {
var calls = 0;
final provider = Provider<VideoPlayerNotifier>((ref) {
return VideoPlayerNotifier(ref, initializer: () async {
calls++;
});
});
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(provider);

await notifier.init();
await notifier.init();

expect(calls, 2);
});
}
Loading