Merge daff33671e into b2a32d178f
This commit is contained in:
commit
f706de8eb5
9 changed files with 192 additions and 5 deletions
|
|
@ -95,6 +95,7 @@ abstract class NativeVideoStoreBase
|
|||
);
|
||||
ReactionDisposer? _disposeAndroidAutoPipReaction;
|
||||
ReactionDisposer? _disposeVideoModeReaction;
|
||||
ReactionDisposer? _disposeBackgroundPlaybackReaction;
|
||||
|
||||
/// Cached SimplePip.isAutoPipAvailable result. Populated by the Android
|
||||
/// auto-PiP reaction on first run; reused by dispose to avoid a redundant
|
||||
|
|
@ -213,6 +214,19 @@ abstract class NativeVideoStoreBase
|
|||
},
|
||||
fireImmediately: true,
|
||||
);
|
||||
|
||||
// Mirror the background-playback setting onto the plugin's foreground
|
||||
// service. Driving it from a reaction (not just _onPlaying) means it
|
||||
// fires the moment the toggle flips while the app is visible, which is
|
||||
// what grants the service Android 17's while-in-use capability.
|
||||
_disposeBackgroundPlaybackReaction = reaction(
|
||||
(_) => settingsStore.backgroundPlayback,
|
||||
(bool enabled) {
|
||||
debugPrint('[BGAUDIO] setBackgroundPlaybackEnabled($enabled)');
|
||||
_controller?.setBackgroundPlaybackEnabled(enabled);
|
||||
},
|
||||
fireImmediately: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -515,6 +529,12 @@ abstract class NativeVideoStoreBase
|
|||
_pendingQualityIndex = null;
|
||||
_setStreamQualityIndex(index);
|
||||
}
|
||||
// Start the background-audio foreground service now that the MediaSession
|
||||
// exists and we're playing in the foreground (Android 17 grants the
|
||||
// service while-in-use capability only when started while visible).
|
||||
if (Platform.isAndroid && settingsStore.backgroundPlayback) {
|
||||
_controller?.setBackgroundPlaybackEnabled(true);
|
||||
}
|
||||
_updateLatency();
|
||||
}
|
||||
|
||||
|
|
@ -1224,6 +1244,7 @@ abstract class NativeVideoStoreBase
|
|||
_stopStreamInfoTimer();
|
||||
_disposeAndroidAutoPipReaction?.call();
|
||||
_disposeVideoModeReaction?.call();
|
||||
_disposeBackgroundPlaybackReaction?.call();
|
||||
_disposeController();
|
||||
}
|
||||
|
||||
|
|
|
|||
47
lib/screens/channel/video/screen_lock_audio_policy.dart
Normal file
47
lib/screens/channel/video/screen_lock_audio_policy.dart
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/// Decides when the native player should drop to audio-only because the screen
|
||||
/// turned off, and what video quality to restore when it turns back on.
|
||||
///
|
||||
/// Pure logic with no Flutter/MobX dependency so it can be unit-tested without
|
||||
/// constructing the heavyweight video store (mirrors ChatLatencySync).
|
||||
class ScreenLockAudioPolicy {
|
||||
int? _savedQualityIndex;
|
||||
|
||||
/// The screen turned off. Returns the audio-only quality index to switch to
|
||||
/// (remembering the current index for restore), or null if nothing should
|
||||
/// change — disabled, not actively playing, user-paused, offline, mid ad
|
||||
/// break, no audio-only quality available, or already audio-only.
|
||||
int? onScreenOff({
|
||||
required bool backgroundPlaybackEnabled,
|
||||
required bool playing,
|
||||
required bool userPaused,
|
||||
required int currentQualityIndex,
|
||||
required int? audioOnlyQualityIndex,
|
||||
required bool offline,
|
||||
required bool adBreakActive,
|
||||
}) {
|
||||
if (!backgroundPlaybackEnabled ||
|
||||
!playing ||
|
||||
userPaused ||
|
||||
offline ||
|
||||
adBreakActive) {
|
||||
return null;
|
||||
}
|
||||
if (audioOnlyQualityIndex == null ||
|
||||
currentQualityIndex == audioOnlyQualityIndex) {
|
||||
return null;
|
||||
}
|
||||
_savedQualityIndex = currentQualityIndex;
|
||||
return audioOnlyQualityIndex;
|
||||
}
|
||||
|
||||
/// The screen turned on. Returns the quality index to restore (then clears
|
||||
/// it), or null if the screen-off didn't switch.
|
||||
int? onScreenOn() {
|
||||
final saved = _savedQualityIndex;
|
||||
_savedQualityIndex = null;
|
||||
return saved;
|
||||
}
|
||||
|
||||
/// Drop any pending restore (e.g. on dispose or a hard refresh).
|
||||
void reset() => _savedQualityIndex = null;
|
||||
}
|
||||
|
|
@ -66,6 +66,7 @@ abstract class _SettingsStoreBase with Store {
|
|||
static const defaultDefaultToHighestQuality = false;
|
||||
static const defaultUseTextureRendering = true;
|
||||
static const defaultUseNativePlayer = false;
|
||||
static const defaultBackgroundPlayback = false;
|
||||
|
||||
static const defaultShowOverlay = true;
|
||||
static const defaultToggleableOverlay = false;
|
||||
|
|
@ -88,6 +89,11 @@ abstract class _SettingsStoreBase with Store {
|
|||
@observable
|
||||
var useNativePlayer = defaultUseNativePlayer;
|
||||
|
||||
/// Keep audio playing when the screen is locked (native player only).
|
||||
@JsonKey(defaultValue: defaultBackgroundPlayback)
|
||||
@observable
|
||||
var backgroundPlayback = defaultBackgroundPlayback;
|
||||
|
||||
// Overlay options
|
||||
@JsonKey(defaultValue: defaultShowOverlay)
|
||||
@observable
|
||||
|
|
@ -107,6 +113,7 @@ abstract class _SettingsStoreBase with Store {
|
|||
defaultToHighestQuality = defaultDefaultToHighestQuality;
|
||||
useTextureRendering = defaultUseTextureRendering;
|
||||
useNativePlayer = defaultUseNativePlayer;
|
||||
backgroundPlayback = defaultBackgroundPlayback;
|
||||
|
||||
showOverlay = defaultShowOverlay;
|
||||
toggleableOverlay = defaultToggleableOverlay;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ SettingsStore _$SettingsStoreFromJson(
|
|||
..defaultToHighestQuality = json['defaultToHighestQuality'] as bool? ?? false
|
||||
..useTextureRendering = json['useTextureRendering'] as bool? ?? true
|
||||
..useNativePlayer = json['useNativePlayer'] as bool? ?? false
|
||||
..backgroundPlayback = json['backgroundPlayback'] as bool? ?? false
|
||||
..showOverlay = json['showOverlay'] as bool? ?? true
|
||||
..toggleableOverlay = json['toggleableOverlay'] as bool? ?? false
|
||||
..showLatency = json['showLatency'] as bool? ?? false
|
||||
|
|
@ -106,6 +107,7 @@ Map<String, dynamic> _$SettingsStoreToJson(
|
|||
'defaultToHighestQuality': instance.defaultToHighestQuality,
|
||||
'useTextureRendering': instance.useTextureRendering,
|
||||
'useNativePlayer': instance.useNativePlayer,
|
||||
'backgroundPlayback': instance.backgroundPlayback,
|
||||
'showOverlay': instance.showOverlay,
|
||||
'toggleableOverlay': instance.toggleableOverlay,
|
||||
'showLatency': instance.showLatency,
|
||||
|
|
@ -350,6 +352,24 @@ mixin _$SettingsStore on _SettingsStoreBase, Store {
|
|||
});
|
||||
}
|
||||
|
||||
late final _$backgroundPlaybackAtom = Atom(
|
||||
name: '_SettingsStoreBase.backgroundPlayback',
|
||||
context: context,
|
||||
);
|
||||
|
||||
@override
|
||||
bool get backgroundPlayback {
|
||||
_$backgroundPlaybackAtom.reportRead();
|
||||
return super.backgroundPlayback;
|
||||
}
|
||||
|
||||
@override
|
||||
set backgroundPlayback(bool value) {
|
||||
_$backgroundPlaybackAtom.reportWrite(value, super.backgroundPlayback, () {
|
||||
super.backgroundPlayback = value;
|
||||
});
|
||||
}
|
||||
|
||||
late final _$showOverlayAtom = Atom(
|
||||
name: '_SettingsStoreBase.showOverlay',
|
||||
context: context,
|
||||
|
|
@ -1231,6 +1251,7 @@ showVideo: ${showVideo},
|
|||
defaultToHighestQuality: ${defaultToHighestQuality},
|
||||
useTextureRendering: ${useTextureRendering},
|
||||
useNativePlayer: ${useNativePlayer},
|
||||
backgroundPlayback: ${backgroundPlayback},
|
||||
showOverlay: ${showOverlay},
|
||||
toggleableOverlay: ${toggleableOverlay},
|
||||
showLatency: ${showLatency},
|
||||
|
|
|
|||
|
|
@ -34,6 +34,17 @@ class VideoSettings extends StatelessWidget {
|
|||
onChanged: (newValue) =>
|
||||
settingsStore.useNativePlayer = newValue,
|
||||
),
|
||||
if (settingsStore.showVideo && settingsStore.useNativePlayer)
|
||||
SettingsListSwitch(
|
||||
title: 'Background audio when screen locked',
|
||||
subtitle: const Text(
|
||||
'Keep the stream playing as audio-only when the screen is off. '
|
||||
'Native player only.',
|
||||
),
|
||||
value: settingsStore.backgroundPlayback,
|
||||
onChanged: (newValue) =>
|
||||
settingsStore.backgroundPlayback = newValue,
|
||||
),
|
||||
if (!Platform.isIOS || isIPad())
|
||||
SettingsListSwitch(
|
||||
title: 'Default to highest quality',
|
||||
|
|
|
|||
|
|
@ -101,9 +101,9 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: frosty-patches
|
||||
resolved-ref: "6460859318b7b44034fe44016cddff02d0bd4c0a"
|
||||
url: "https://github.com/tommyxchow/flutter-native-video-player.git"
|
||||
ref: background-playback
|
||||
resolved-ref: "01f20e0a62fc79a839f5cc3eff052c2d2d602a59"
|
||||
url: "https://github.com/francislavoie/flutter-native-video-player.git"
|
||||
source: git
|
||||
version: "0.4.11"
|
||||
boolean_selector:
|
||||
|
|
|
|||
|
|
@ -81,8 +81,12 @@ dependencies:
|
|||
native_device_orientation: ^2.1.0
|
||||
better_native_video_player:
|
||||
git:
|
||||
url: https://github.com/tommyxchow/flutter-native-video-player.git
|
||||
ref: frosty-patches
|
||||
# TEMP: points at francislavoie's fork while developing the
|
||||
# background-playback foreground service. Revert to
|
||||
# tommyxchow/flutter-native-video-player @ frosty-patches once the
|
||||
# change is merged upstream. Branch is based on the same pinned ref.
|
||||
url: https://github.com/francislavoie/flutter-native-video-player.git
|
||||
ref: background-playback
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/screens/channel/video/screen_lock_audio_policy.dart';
|
||||
|
||||
void main() {
|
||||
late ScreenLockAudioPolicy policy;
|
||||
setUp(() => policy = ScreenLockAudioPolicy());
|
||||
|
||||
int? off({
|
||||
bool enabled = true,
|
||||
bool playing = true,
|
||||
bool userPaused = false,
|
||||
int current = 2,
|
||||
int? audioOnly = 5,
|
||||
bool offline = false,
|
||||
bool ad = false,
|
||||
}) => policy.onScreenOff(
|
||||
backgroundPlaybackEnabled: enabled,
|
||||
playing: playing,
|
||||
userPaused: userPaused,
|
||||
currentQualityIndex: current,
|
||||
audioOnlyQualityIndex: audioOnly,
|
||||
offline: offline,
|
||||
adBreakActive: ad,
|
||||
);
|
||||
|
||||
test('switches to the audio-only index and restores the prior index', () {
|
||||
expect(off(), 5); // current defaults to 2, audio-only to 5
|
||||
expect(policy.onScreenOn(), 2);
|
||||
});
|
||||
|
||||
test('onScreenOn returns null once consumed', () {
|
||||
off();
|
||||
policy.onScreenOn();
|
||||
expect(policy.onScreenOn(), isNull);
|
||||
});
|
||||
|
||||
test('no-op when disabled', () => expect(off(enabled: false), isNull));
|
||||
test('no-op when paused', () => expect(off(playing: false), isNull));
|
||||
test('no-op when user paused', () => expect(off(userPaused: true), isNull));
|
||||
test('no-op when offline', () => expect(off(offline: true), isNull));
|
||||
test('no-op during ad break', () => expect(off(ad: true), isNull));
|
||||
test(
|
||||
'no-op when no audio-only quality',
|
||||
() => expect(off(audioOnly: null), isNull),
|
||||
);
|
||||
test(
|
||||
'no-op when already audio-only',
|
||||
() => expect(off(current: 5), isNull), // current == audio-only default (5)
|
||||
);
|
||||
|
||||
test('onScreenOn returns null if screen-off was a no-op', () {
|
||||
off(enabled: false);
|
||||
expect(policy.onScreenOn(), isNull);
|
||||
});
|
||||
|
||||
test('reset clears a pending restore', () {
|
||||
off();
|
||||
policy.reset();
|
||||
expect(policy.onScreenOn(), isNull);
|
||||
});
|
||||
}
|
||||
15
test/screens/settings/background_playback_setting_test.dart
Normal file
15
test/screens/settings/background_playback_setting_test.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:frosty/screens/settings/stores/settings_store.dart';
|
||||
|
||||
void main() {
|
||||
test('backgroundPlayback defaults to false', () {
|
||||
final store = SettingsStore.fromJson({});
|
||||
expect(store.backgroundPlayback, isFalse);
|
||||
});
|
||||
|
||||
test('backgroundPlayback round-trips through json', () {
|
||||
final store = SettingsStore.fromJson({})..backgroundPlayback = true;
|
||||
final restored = SettingsStore.fromJson(store.toJson());
|
||||
expect(restored.backgroundPlayback, isTrue);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue