diff --git a/lib/screens/channel/video/native_video_store.dart b/lib/screens/channel/video/native_video_store.dart index e0b789bc..9f053e16 100644 --- a/lib/screens/channel/video/native_video_store.dart +++ b/lib/screens/channel/video/native_video_store.dart @@ -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(); } diff --git a/lib/screens/channel/video/screen_lock_audio_policy.dart b/lib/screens/channel/video/screen_lock_audio_policy.dart new file mode 100644 index 00000000..de1077b4 --- /dev/null +++ b/lib/screens/channel/video/screen_lock_audio_policy.dart @@ -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; +} diff --git a/lib/screens/settings/stores/settings_store.dart b/lib/screens/settings/stores/settings_store.dart index 333f4e38..256d10c0 100644 --- a/lib/screens/settings/stores/settings_store.dart +++ b/lib/screens/settings/stores/settings_store.dart @@ -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; diff --git a/lib/screens/settings/stores/settings_store.g.dart b/lib/screens/settings/stores/settings_store.g.dart index 2e713294..44953841 100644 --- a/lib/screens/settings/stores/settings_store.g.dart +++ b/lib/screens/settings/stores/settings_store.g.dart @@ -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 _$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}, diff --git a/lib/screens/settings/video_settings.dart b/lib/screens/settings/video_settings.dart index 4e2aedd4..0d82963a 100644 --- a/lib/screens/settings/video_settings.dart +++ b/lib/screens/settings/video_settings.dart @@ -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', diff --git a/pubspec.lock b/pubspec.lock index 9e8b63a4..fadc7c03 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 3bc63b18..a7b88f77 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/screens/channel/video/screen_lock_audio_policy_test.dart b/test/screens/channel/video/screen_lock_audio_policy_test.dart new file mode 100644 index 00000000..ac7adc54 --- /dev/null +++ b/test/screens/channel/video/screen_lock_audio_policy_test.dart @@ -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); + }); +} diff --git a/test/screens/settings/background_playback_setting_test.dart b/test/screens/settings/background_playback_setting_test.dart new file mode 100644 index 00000000..5eb14d8d --- /dev/null +++ b/test/screens/settings/background_playback_setting_test.dart @@ -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); + }); +}