This commit is contained in:
Francis Lavoie 2026-07-10 09:11:01 -07:00 • committed by GitHub
commit f706de8eb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 192 additions and 5 deletions

View file

@ -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();
}

View 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;
}

View file

@ -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;

View file

@ -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},

View file

@ -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',

View file

@ -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:

View file

@ -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:

View file

@ -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);
});
}

View 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);
});
}