fix(automotive): stop playback while a vehicle restricts the app
Plezy declares appCategory="video", so on Android Automotive OS it is a parked app bound by car app quality DD-2/DD-3: audio must stop when the vehicle starts driving and must not be resumable while driving. Two paths kept audio alive. Music playback ran under a mediaPlayback foreground service whose lifecycle observer was registered for Apple TV only, so it never paused when Android backgrounded the app. Video pausing hung off AppLifecycleState.hidden, which Flutter only synthesizes once Android delivers onStop; a car without the Automotive compatibility mode delivers onPause alone, which maps to AppLifecycleState.inactive and the player ignored. Gate every path that can start audio on a new lifecycle predicate, automotivePlaybackAllowed, which permits playback on a car only while the app is resumed and fails closed on an unknown lifecycle state. That covers explicit play, gapless arming and track transitions, live retry and channel switch, frame-rate-match resume, VOD/live startup, and the queue navigation commands of the OS media session, plus a last-resort pause for when the platform player resumes itself on native audio-focus regain. Playback authority on the media-session router is deliberately left alone: the router consumes a denied event, so gating it would swallow PauseEvent and leave the OS unable to stop audio. Reacting to lifecycle callbacks is the mechanism the platform documents as sufficient, so no android.car dependency is added. The music queue no longer requests POST_NOTIFICATIONS on a car, where the foreground service and its notification never start: there is nothing to authorize, and the prompt would take focus and make the gate discard the first play intent. Detect the form factor too: FEATURE_AUTOMOTIVE now vetoes the Android TV verdict, so a rotary-only head unit no longer inherits the leanback experience. Picture-in-picture is gated on FEATURE_PICTURE_IN_PICTURE, which cars lack, so the app's UI cannot stay on screen while driving, and nothing forces a preferred orientation on a fixed-orientation display.
This commit is contained in:
@@ -96,6 +96,8 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
private fun isAndroidTvDevice(): Boolean = getAndroidTvDetection()["isTv"] as Boolean
|
private fun isAndroidTvDevice(): Boolean = getAndroidTvDetection()["isTv"] as Boolean
|
||||||
|
|
||||||
|
private fun isPipSupportedDevice(): Boolean = !isAndroidTvDevice() && packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||||
|
|
||||||
private fun isImeVisible(): Boolean {
|
private fun isImeVisible(): Boolean {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false
|
||||||
return window.decorView.rootWindowInsets?.isVisible(WindowInsets.Type.ime()) == true
|
return window.decorView.rootWindowInsets?.isVisible(WindowInsets.Type.ime()) == true
|
||||||
@@ -168,6 +170,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
val hasFireTvFeature = pm.hasSystemFeature("amazon.hardware.fire_tv")
|
val hasFireTvFeature = pm.hasSystemFeature("amazon.hardware.fire_tv")
|
||||||
val hasTouchscreen = pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)
|
val hasTouchscreen = pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)
|
||||||
val hasFakeTouch = pm.hasSystemFeature(PackageManager.FEATURE_FAKETOUCH)
|
val hasFakeTouch = pm.hasSystemFeature(PackageManager.FEATURE_FAKETOUCH)
|
||||||
|
val isAutomotive = pm.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)
|
||||||
|
|
||||||
val reasons = mutableListOf<String>()
|
val reasons = mutableListOf<String>()
|
||||||
if (isTelevisionUiMode) reasons.add("ui_mode_television")
|
if (isTelevisionUiMode) reasons.add("ui_mode_television")
|
||||||
@@ -177,7 +180,11 @@ class MainActivity : FlutterActivity() {
|
|||||||
if (!hasTouchscreen) reasons.add("no_touchscreen")
|
if (!hasTouchscreen) reasons.add("no_touchscreen")
|
||||||
|
|
||||||
return mapOf(
|
return mapOf(
|
||||||
"isTv" to reasons.isNotEmpty(),
|
// A car is never a TV: rotary-only head units report no touchscreen, and
|
||||||
|
// an OEM image can carry a stray leanback flag. Keep the raw reasons for
|
||||||
|
// diagnostics, but never let them promote a vehicle to the TV experience.
|
||||||
|
"isTv" to (!isAutomotive && reasons.isNotEmpty()),
|
||||||
|
"isAutomotive" to isAutomotive,
|
||||||
"reasons" to reasons,
|
"reasons" to reasons,
|
||||||
"isTelevisionUiMode" to isTelevisionUiMode,
|
"isTelevisionUiMode" to isTelevisionUiMode,
|
||||||
"hasTelevisionFeature" to hasTelevisionFeature,
|
"hasTelevisionFeature" to hasTelevisionFeature,
|
||||||
@@ -662,7 +669,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL).setMethodCallHandler { call, result ->
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL).setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"isSupported" -> {
|
"isSupported" -> {
|
||||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAndroidTvDevice())
|
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isPipSupportedDevice())
|
||||||
}
|
}
|
||||||
"enter" -> {
|
"enter" -> {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
@@ -670,7 +677,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
return@setMethodCallHandler
|
return@setMethodCallHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAndroidTvDevice()) {
|
if (!isPipSupportedDevice()) {
|
||||||
result.success(mapOf("success" to false, "errorCode" to "not_supported"))
|
result.success(mapOf("success" to false, "errorCode" to "not_supported"))
|
||||||
return@setMethodCallHandler
|
return@setMethodCallHandler
|
||||||
}
|
}
|
||||||
@@ -697,7 +704,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"setAutoPipReady" -> {
|
"setAutoPipReady" -> {
|
||||||
if (isAndroidTvDevice()) {
|
if (!isPipSupportedDevice()) {
|
||||||
autoPipReady = false
|
autoPipReady = false
|
||||||
result.success(true)
|
result.success(true)
|
||||||
return@setMethodCallHandler
|
return@setMethodCallHandler
|
||||||
@@ -821,7 +828,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
override fun onUserLeaveHint() {
|
override fun onUserLeaveHint() {
|
||||||
super.onUserLeaveHint()
|
super.onUserLeaveHint()
|
||||||
// Auto PiP for API 26-30 (API 31+ uses setAutoEnterEnabled)
|
// Auto PiP for API 26-30 (API 31+ uses setAutoEnterEnabled)
|
||||||
if (!isAndroidTvDevice() &&
|
if (isPipSupportedDevice() &&
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S &&
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.S &&
|
||||||
autoPipReady &&
|
autoPipReady &&
|
||||||
|
|||||||
@@ -5,15 +5,19 @@ import android.content.pm.PackageManager
|
|||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Native mirror of MainActivity.getAndroidTvDetection(): any TV signal counts.
|
* Native mirror of MainActivity.getAndroidTvDetection(): any TV signal counts,
|
||||||
* Kept in sync with the Dart-facing detection so native gating matches
|
* except that FEATURE_AUTOMOTIVE vetoes the verdict outright. Kept in sync with
|
||||||
* PlatformDetector.isTV().
|
* the Dart-facing detection so native gating matches PlatformDetector.isTV().
|
||||||
*/
|
*/
|
||||||
object TvDetection {
|
object TvDetection {
|
||||||
fun isTv(context: Context): Boolean {
|
fun isTv(context: Context): Boolean {
|
||||||
val pm = context.packageManager
|
val pm = context.packageManager
|
||||||
val uiModeType = context.resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK
|
val uiModeType = context.resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK
|
||||||
|
|
||||||
|
// A car is never a TV: rotary-only head units report no touchscreen, and an
|
||||||
|
// OEM image can carry a stray leanback flag.
|
||||||
|
if (pm.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) return false
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
return uiModeType == Configuration.UI_MODE_TYPE_TELEVISION ||
|
return uiModeType == Configuration.UI_MODE_TYPE_TELEVISION ||
|
||||||
pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
|
pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mounted && player != null) {
|
if (mounted && player != null) {
|
||||||
await player!.play();
|
await _playWithPlaybackIntent(player!);
|
||||||
}
|
}
|
||||||
|
|
||||||
unawaited(
|
unawaited(
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
part of '../../video_player_screen.dart';
|
part of '../../video_player_screen.dart';
|
||||||
|
|
||||||
|
bool shouldPauseVideoForBackground({required bool isHandheld, required bool isTv, required bool isAutomotive}) =>
|
||||||
|
isHandheld || isTv || isAutomotive;
|
||||||
|
|
||||||
extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
||||||
void _enqueueLifecycleTransition(String label, Future<void> Function() transition) {
|
void _enqueueLifecycleTransition(String label, Future<void> Function() transition) {
|
||||||
_lifecycleTransition = _lifecycleTransition
|
_lifecycleTransition = _lifecycleTransition
|
||||||
@@ -108,16 +111,32 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final shouldPauseForBackground = PlatformDetector.isHandheld(context) || isTv;
|
final isAutomotive = PlatformDetector.isAutomotive();
|
||||||
|
final shouldPauseForBackground = shouldPauseVideoForBackground(
|
||||||
|
isHandheld: PlatformDetector.isHandheld(context),
|
||||||
|
isTv: isTv,
|
||||||
|
isAutomotive: isAutomotive,
|
||||||
|
);
|
||||||
|
|
||||||
// Pause first so Android MPV does not keep decoding against a transient
|
// Pause first so Android MPV does not keep decoding against a transient
|
||||||
// background surface while the app is locking or hiding.
|
// background surface while the app is locking or hiding.
|
||||||
if (shouldPauseForBackground) {
|
if (shouldPauseForBackground) {
|
||||||
_wasPlayingBeforeInactive = currentPlayer.state.isActive;
|
// Sticky latch: a car with the Automotive compatibility mode delivers
|
||||||
if (_wasPlayingBeforeInactive) {
|
// onPause *and* onStop, so this runs twice, and the second pass must not
|
||||||
|
// overwrite the latch with the already-paused state. Cleared on resume.
|
||||||
|
final wasActive = currentPlayer.state.isActive;
|
||||||
|
_wasPlayingBeforeInactive = _wasPlayingBeforeInactive || wasActive;
|
||||||
|
if (wasActive) {
|
||||||
try {
|
try {
|
||||||
await _pauseWithPlaybackIntent(currentPlayer);
|
await _pauseWithPlaybackIntent(currentPlayer);
|
||||||
appLogger.d('Video paused due to app being hidden (${isTv ? 'tv' : 'handheld'})');
|
appLogger.d(
|
||||||
|
'Video paused due to app being hidden '
|
||||||
|
'(${isAutomotive
|
||||||
|
? 'automotive'
|
||||||
|
: isTv
|
||||||
|
? 'tv'
|
||||||
|
: 'handheld'})',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.w('Failed to pause video before background transition', error: e);
|
appLogger.w('Failed to pause video before background transition', error: e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,8 +121,11 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
|||||||
recover: () => session.recover(directStream: ds, directStreamAudio: dsa),
|
recover: () => session.recover(directStream: ds, directStreamAudio: dsa),
|
||||||
lookupStreamUrl: (recovered) => recovered.streamUrlAt(),
|
lookupStreamUrl: (recovered) => recovered.streamUrlAt(),
|
||||||
applyPlayerOptions: () => _setLiveStreamOptions(currentPlayer),
|
applyPlayerOptions: () => _setLiveStreamOptions(currentPlayer),
|
||||||
open: (streamUrl) =>
|
open: (streamUrl) => currentPlayer.open(
|
||||||
currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true),
|
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
|
||||||
|
play: automotivePlaybackAllowedNow(),
|
||||||
|
isLive: true,
|
||||||
|
),
|
||||||
isCurrent: isCurrent,
|
isCurrent: isCurrent,
|
||||||
adoptSession: (recovered) {
|
adoptSession: (recovered) {
|
||||||
_live.adoptSession(recovered);
|
_live.adoptSession(recovered);
|
||||||
@@ -198,7 +201,11 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
|||||||
_live.playbackStartTime = DateTime.now();
|
_live.playbackStartTime = DateTime.now();
|
||||||
|
|
||||||
await _setLiveStreamOptions(currentPlayer);
|
await _setLiveStreamOptions(currentPlayer);
|
||||||
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
|
await currentPlayer.open(
|
||||||
|
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
|
||||||
|
play: automotivePlaybackAllowedNow(),
|
||||||
|
isLive: true,
|
||||||
|
);
|
||||||
if (mounted) _setPlayerState(() {});
|
if (mounted) _setPlayerState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +312,11 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState {
|
|||||||
_hasRenderedFirstFrame = false;
|
_hasRenderedFirstFrame = false;
|
||||||
});
|
});
|
||||||
replacementOpenStarted = true;
|
replacementOpenStarted = true;
|
||||||
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
|
await currentPlayer.open(
|
||||||
|
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
|
||||||
|
play: automotivePlaybackAllowedNow(),
|
||||||
|
isLive: true,
|
||||||
|
);
|
||||||
if (!isCurrentChannelSwitch()) {
|
if (!isCurrentChannelSwitch()) {
|
||||||
_abandonLiveSession(session);
|
_abandonLiveSession(session);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -468,8 +468,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
|||||||
_mediaControlsManager = mediaControlsManager;
|
_mediaControlsManager = mediaControlsManager;
|
||||||
|
|
||||||
final mediaControlRouter = MediaControlRouter(
|
final mediaControlRouter = MediaControlRouter(
|
||||||
|
// Authority stays Watch Together's. The automotive gate lives in the
|
||||||
|
// playback-intent wrappers below, so `onPause` can never be denied: a
|
||||||
|
// gated `canControlPlayback` would make the router swallow `PauseEvent`.
|
||||||
canControlPlayback: _canControlPlayback,
|
canControlPlayback: _canControlPlayback,
|
||||||
canNavigateMediaItems: _canNavigateMediaItems,
|
canNavigateMediaItems: () => _canNavigateMediaItems() && automotivePlaybackAllowedNow(),
|
||||||
onPlay: () {
|
onPlay: () {
|
||||||
final currentPlayer = player;
|
final currentPlayer = player;
|
||||||
if (currentPlayer == null) return;
|
if (currentPlayer == null) return;
|
||||||
@@ -592,6 +595,24 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
|||||||
_lastPlaybackPauseAt = DateTime.now();
|
_lastPlaybackPauseAt = DateTime.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPlaying && !automotivePlaybackAllowedNow()) {
|
||||||
|
// Native audio-focus regain resumes the platform player directly
|
||||||
|
// (ExoPlayer's AudioFocusManager, mpv's resumeAfterAudioFocusGain), so it
|
||||||
|
// never passes through the Dart playback-intent wrappers. Last line of
|
||||||
|
// defence for `DD-2`: audio must not resume while the vehicle restricts
|
||||||
|
// the app. Also catches any async open that raced the lifecycle pause.
|
||||||
|
appLogger.w('Playback started while Android Automotive UX restrictions are active; pausing');
|
||||||
|
Sentry.addBreadcrumb(
|
||||||
|
Breadcrumb(message: 'Blocked automotive restricted playback start', category: 'player.driver_distraction'),
|
||||||
|
);
|
||||||
|
final currentPlayer = player;
|
||||||
|
if (currentPlayer != null) {
|
||||||
|
unawaited(_pauseWithPlaybackIntent(currentPlayer));
|
||||||
|
}
|
||||||
|
unawaited(_wakelockController.setEnabled(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isPlaying && _mediaControlsSuspendedForTvBackground) {
|
if (isPlaying && _mediaControlsSuspendedForTvBackground) {
|
||||||
appLogger.w('Playback started while Android TV background media controls are suspended; pausing');
|
appLogger.w('Playback started while Android TV background media controls are suspended; pausing');
|
||||||
Sentry.addBreadcrumb(
|
Sentry.addBreadcrumb(
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await currentPlayer.setProperty('force-seekable', 'no');
|
await currentPlayer.setProperty('force-seekable', 'no');
|
||||||
await currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true);
|
await currentPlayer.open(
|
||||||
|
Media(streamUrl, headers: const {'Accept-Language': 'en'}),
|
||||||
|
play: !PlatformDetector.isAutomotive(),
|
||||||
|
isLive: true,
|
||||||
|
);
|
||||||
if (!attempt.isCurrent) return;
|
if (!attempt.isCurrent) return;
|
||||||
|
|
||||||
_trackManager?.cacheExternalSubtitles(const []);
|
_trackManager?.cacheExternalSubtitles(const []);
|
||||||
@@ -86,6 +90,9 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
|||||||
});
|
});
|
||||||
_trackManager?.mediaInfo = null;
|
_trackManager?.mediaInfo = null;
|
||||||
}
|
}
|
||||||
|
if (PlatformDetector.isAutomotive()) {
|
||||||
|
await _playWithPlaybackIntent(currentPlayer);
|
||||||
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
appLogger.e('Failed to start live TV playback', error: e, stackTrace: st);
|
appLogger.e('Failed to start live TV playback', error: e, stackTrace: st);
|
||||||
unawaited(_sendLiveTimeline('stopped'));
|
unawaited(_sendLiveTimeline('stopped'));
|
||||||
@@ -257,7 +264,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
|||||||
selectedVersion: result.selectedVersion,
|
selectedVersion: result.selectedVersion,
|
||||||
timing: openTiming,
|
timing: openTiming,
|
||||||
headers: streamHeaders,
|
headers: streamHeaders,
|
||||||
play: shouldAutoPlay,
|
play: shouldAutoPlay && !PlatformDetector.isAutomotive(),
|
||||||
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
|
externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen,
|
||||||
shouldContinue: () => attempt.isCurrent,
|
shouldContinue: () => attempt.isCurrent,
|
||||||
onMediaAvailabilityChanged: (available) => primaryMediaOpened = available,
|
onMediaAvailabilityChanged: (available) => primaryMediaOpened = available,
|
||||||
@@ -279,6 +286,10 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
|||||||
_attachToWatchTogetherSession(startupHold: wtStartupHold?.future);
|
_attachToWatchTogetherSession(startupHold: wtStartupHold?.future);
|
||||||
_notifyWatchTogetherMediaChange();
|
_notifyWatchTogetherMediaChange();
|
||||||
}
|
}
|
||||||
|
if (shouldAutoPlay && PlatformDetector.isAutomotive()) {
|
||||||
|
await _playWithPlaybackIntent(currentPlayer);
|
||||||
|
if (!attempt.isCurrent) return;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
|
externalSubtitlePlan = _prepareExternalSubtitleOpenPlan(
|
||||||
player: currentPlayer,
|
player: currentPlayer,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import '../models/companion_remote/remote_command.dart';
|
|||||||
import '../providers/companion_remote_provider.dart';
|
import '../providers/companion_remote_provider.dart';
|
||||||
import '../services/companion_remote/companion_remote_receiver.dart';
|
import '../services/companion_remote/companion_remote_receiver.dart';
|
||||||
import '../services/fullscreen_state_manager.dart';
|
import '../services/fullscreen_state_manager.dart';
|
||||||
|
import '../services/driver_distraction.dart';
|
||||||
import '../services/discord_rpc_service.dart';
|
import '../services/discord_rpc_service.dart';
|
||||||
import '../services/trackers/tracker_coordinator.dart';
|
import '../services/trackers/tracker_coordinator.dart';
|
||||||
import '../services/trakt/trakt_scrobble_service.dart';
|
import '../services/trakt/trakt_scrobble_service.dart';
|
||||||
@@ -689,6 +690,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _playWithPlaybackIntent(Player currentPlayer) {
|
Future<void> _playWithPlaybackIntent(Player currentPlayer) {
|
||||||
|
if (!automotivePlaybackAllowedNow()) {
|
||||||
|
_playbackIntentShouldPlay = false;
|
||||||
|
appLogger.d('Playback blocked while Android Automotive app is not resumed');
|
||||||
|
return Future<void>.value();
|
||||||
|
}
|
||||||
_playbackIntentShouldPlay = true;
|
_playbackIntentShouldPlay = true;
|
||||||
if (widget.isLive && _live.retryFailed) {
|
if (widget.isLive && _live.retryFailed) {
|
||||||
if (_live.retrying) return Future.value();
|
if (_live.retrying) return Future.value();
|
||||||
@@ -710,6 +716,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _playOrPauseWithPlaybackIntent(Player currentPlayer) {
|
Future<void> _playOrPauseWithPlaybackIntent(Player currentPlayer) {
|
||||||
|
if (!automotivePlaybackAllowedNow()) {
|
||||||
|
appLogger.d('Play/pause requested while Android Automotive app is not resumed; keeping playback paused');
|
||||||
|
return _pauseWithPlaybackIntent(currentPlayer);
|
||||||
|
}
|
||||||
if (widget.isLive && _live.retryFailed) {
|
if (widget.isLive && _live.retryFailed) {
|
||||||
return _playWithPlaybackIntent(currentPlayer);
|
return _playWithPlaybackIntent(currentPlayer);
|
||||||
}
|
}
|
||||||
@@ -867,6 +877,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
switch (state) {
|
switch (state) {
|
||||||
case AppLifecycleState.inactive:
|
case AppLifecycleState.inactive:
|
||||||
_recordLifecycleState('inactive');
|
_recordLifecycleState('inactive');
|
||||||
|
if (PlatformDetector.isAutomotive()) {
|
||||||
|
_enqueueLifecycleTransition('inactive_automotive', _handleAppHidden);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case AppLifecycleState.hidden:
|
case AppLifecycleState.hidden:
|
||||||
_recordLifecycleState('hidden');
|
_recordLifecycleState('hidden');
|
||||||
@@ -1463,6 +1476,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
appLogger.w('Failed to restore system UI', error: e);
|
appLogger.w('Failed to restore system UI', error: e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cars are fixed-orientation devices, and a compact head unit can read as a
|
||||||
|
// phone below, which would pin it to portrait on player exit.
|
||||||
|
if (PlatformDetector.isAutomotive()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (_isPhone) {
|
if (_isPhone) {
|
||||||
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
|
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../utils/platform_detector.dart';
|
||||||
|
|
||||||
|
/// Android Automotive OS driver-distraction gating for Plezy's `video` app
|
||||||
|
/// category (car app quality `DD-2` / `DD-3`).
|
||||||
|
///
|
||||||
|
/// While a vehicle's user-experience restrictions are active the system hides
|
||||||
|
/// the app's activity. That delivers `onPause` — Flutter
|
||||||
|
/// [AppLifecycleState.inactive] — at minimum; only devices carrying the
|
||||||
|
/// Automotive compatibility mode go on to deliver `onStop`
|
||||||
|
/// ([AppLifecycleState.hidden] then [AppLifecycleState.paused]). Reacting to
|
||||||
|
/// lifecycle callbacks is the mechanism the platform documents as sufficient,
|
||||||
|
/// so playback authority is derived from lifecycle state alone and no
|
||||||
|
/// `android.car` dependency is required.
|
||||||
|
///
|
||||||
|
/// Two obligations follow from `DD-2`, and this single predicate serves both:
|
||||||
|
/// audio must stop when driving starts, and it must not be resumable while
|
||||||
|
/// driving. The second obligation covers every path that can start audio, not
|
||||||
|
/// just OS media-session commands — a gapless track transition or queue
|
||||||
|
/// auto-advance landing just after the lifecycle pause must fail closed too.
|
||||||
|
///
|
||||||
|
/// The gate itself fails closed: an unknown (null) lifecycle state denies
|
||||||
|
/// playback so a command arriving before the first lifecycle message cannot
|
||||||
|
/// slip through; nothing is playing that early, so the strictness costs nothing.
|
||||||
|
bool automotivePlaybackAllowed({required bool isAutomotive, required AppLifecycleState? state}) {
|
||||||
|
if (!isAutomotive) return true;
|
||||||
|
return state == AppLifecycleState.resumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [automotivePlaybackAllowed] against the ambient form factor and lifecycle,
|
||||||
|
/// for owners that hold no injected lifecycle state of their own.
|
||||||
|
///
|
||||||
|
/// Short-circuits before reading [WidgetsBinding.instance] so this stays usable
|
||||||
|
/// from plain `test()` suites, where the binding is not initialized and the
|
||||||
|
/// `instance` getter throws.
|
||||||
|
bool automotivePlaybackAllowedNow() {
|
||||||
|
if (!PlatformDetector.isAutomotive()) return true;
|
||||||
|
return automotivePlaybackAllowed(isAutomotive: true, state: WidgetsBinding.instance.lifecycleState);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import '../../mpv/player/player.dart';
|
|||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
import '../../utils/notification_permission.dart';
|
import '../../utils/notification_permission.dart';
|
||||||
import '../../utils/platform_detector.dart';
|
import '../../utils/platform_detector.dart';
|
||||||
|
import '../driver_distraction.dart';
|
||||||
import '../media_control_router.dart';
|
import '../media_control_router.dart';
|
||||||
import '../media_controls_manager.dart';
|
import '../media_controls_manager.dart';
|
||||||
import '../multi_server_manager.dart';
|
import '../multi_server_manager.dart';
|
||||||
@@ -74,10 +75,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
_coordinator = coordinator ?? PlaybackCoordinator.instance,
|
_coordinator = coordinator ?? PlaybackCoordinator.instance,
|
||||||
_volumePersistenceWriter = volumePersistenceWriter ?? _writePersistedVolume {
|
_volumePersistenceWriter = volumePersistenceWriter ?? _writePersistedVolume {
|
||||||
_coordinator.registerMusicSession(stopAndDispose: _stopForVideoClaim);
|
_coordinator.registerMusicSession(stopAndDispose: _stopForVideoClaim);
|
||||||
// tvOS has no background-audio session in v1 — pause on backgrounding so
|
// tvOS has no background-audio session in v1, so it pauses on
|
||||||
// audio doesn't play over other apps / the home screen. Other platforms
|
// backgrounding. AAOS must stop audio while driving per DD-2. Other
|
||||||
// keep playing under their OS media session.
|
// platforms keep playing under their OS media session.
|
||||||
if (PlatformDetector.isAppleTV()) {
|
if (PlatformDetector.isAppleTV() || PlatformDetector.isAutomotive()) {
|
||||||
_observesLifecycle = true;
|
_observesLifecycle = true;
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
}
|
}
|
||||||
@@ -287,7 +288,15 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
// Android 13+: the background playback notification needs
|
// Android 13+: the background playback notification needs
|
||||||
// POST_NOTIFICATIONS. Fire-and-forget — playback and the foreground
|
// POST_NOTIFICATIONS. Fire-and-forget — playback and the foreground
|
||||||
// service run regardless; a denial only hides the notification.
|
// service run regardless; a denial only hides the notification.
|
||||||
unawaited(NotificationPermission.ensure());
|
//
|
||||||
|
// Skipped on a car, where `setBackgroundMode(false)` means the foreground
|
||||||
|
// service and its notification never start, so there is nothing to
|
||||||
|
// authorize. The prompt would also take focus, leaving the app briefly not
|
||||||
|
// resumed, and the automotive gate would then open the track paused and
|
||||||
|
// silently drop the user's play intent.
|
||||||
|
if (!PlatformDetector.isAutomotive()) {
|
||||||
|
unawaited(NotificationPermission.ensure());
|
||||||
|
}
|
||||||
final generation = ++_generation;
|
final generation = ++_generation;
|
||||||
_invalidateArmRequests();
|
_invalidateArmRequests();
|
||||||
_finalizeCurrentTrack();
|
_finalizeCurrentTrack();
|
||||||
@@ -327,7 +336,8 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
// Re-asserted per open (cheap, idempotent): the native side drops the
|
// Re-asserted per open (cheap, idempotent): the native side drops the
|
||||||
// background-mode opt-in when the user swipes the task away, so a
|
// background-mode opt-in when the user swipes the task away, so a
|
||||||
// session that survives task removal heals itself here.
|
// session that survives task removal heals itself here.
|
||||||
unawaited(_mediaControls?.setBackgroundMode(true));
|
// Passing false on AAOS also heals any stale opt-in from an earlier session.
|
||||||
|
unawaited(_mediaControls?.setBackgroundMode(!PlatformDetector.isAutomotive()));
|
||||||
|
|
||||||
// Clear any native arm left over from the previous item before the open
|
// Clear any native arm left over from the previous item before the open
|
||||||
// replaces it, so a stray transition can't fire mid-switch.
|
// replaces it, so a stray transition can't fire mid-switch.
|
||||||
@@ -358,8 +368,9 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
}
|
}
|
||||||
if (generation != _generation || _player != player) return;
|
if (generation != _generation || _player != player) return;
|
||||||
|
|
||||||
|
final shouldPlay = play && automotivePlaybackAllowedNow();
|
||||||
try {
|
try {
|
||||||
await player.open(Media(source.url, headers: source.headers), play: play);
|
await player.open(Media(source.url, headers: source.headers), play: shouldPlay);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
appLogger.w('Music open failed for ${track.id}', error: e, stackTrace: st);
|
appLogger.w('Music open failed for ${track.id}', error: e, stackTrace: st);
|
||||||
if (generation == _generation) _handlePlaybackFailure(e);
|
if (generation == _generation) _handlePlaybackFailure(e);
|
||||||
@@ -367,8 +378,13 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
}
|
}
|
||||||
if (generation != _generation || _player != player) return;
|
if (generation != _generation || _player != player) return;
|
||||||
|
|
||||||
|
final playbackStarted = shouldPlay && automotivePlaybackAllowedNow();
|
||||||
|
if (shouldPlay && !playbackStarted) {
|
||||||
|
await player.pause();
|
||||||
|
if (generation != _generation || _player != player) return;
|
||||||
|
}
|
||||||
committedPlayer = player;
|
committedPlayer = player;
|
||||||
_setStatus(play ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
|
_setStatus(playbackStarted ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
|
||||||
_bindTrackServices(track, source);
|
_bindTrackServices(track, source);
|
||||||
} finally {
|
} finally {
|
||||||
// A stale open must never release a newer open's ownership. Only a
|
// A stale open must never release a newer open's ownership. Only a
|
||||||
@@ -456,7 +472,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _requestArmNext() {
|
void _requestArmNext() {
|
||||||
if (_disposed) return;
|
if (_disposed || !automotivePlaybackAllowedNow()) return;
|
||||||
_armRequestGeneration++;
|
_armRequestGeneration++;
|
||||||
_armRequestPending = true;
|
_armRequestPending = true;
|
||||||
_ensureArmDrain();
|
_ensureArmDrain();
|
||||||
@@ -502,6 +518,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _trySetNext(Player player, Media? media) async {
|
Future<bool> _trySetNext(Player player, Media? media) async {
|
||||||
|
if (media != null && !automotivePlaybackAllowedNow()) return false;
|
||||||
try {
|
try {
|
||||||
await player.setNext(media);
|
await player.setNext(media);
|
||||||
return true;
|
return true;
|
||||||
@@ -559,14 +576,19 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onPlayingChanged(bool isPlaying) {
|
void _onPlayingChanged(bool isPlaying) {
|
||||||
|
final playbackAllowed = automotivePlaybackAllowedNow();
|
||||||
|
final shouldBePlaying = isPlaying && playbackAllowed;
|
||||||
|
if (isPlaying && !playbackAllowed) {
|
||||||
|
unawaited(_player?.pause());
|
||||||
|
}
|
||||||
if (_status == MusicPlaybackStatus.playing || _status == MusicPlaybackStatus.paused) {
|
if (_status == MusicPlaybackStatus.playing || _status == MusicPlaybackStatus.paused) {
|
||||||
_setStatus(isPlaying ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
|
_setStatus(shouldBePlaying ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
|
||||||
unawaited(_tracker?.sendProgress(isPlaying ? 'playing' : 'paused'));
|
unawaited(_tracker?.sendProgress(shouldBePlaying ? 'playing' : 'paused'));
|
||||||
}
|
}
|
||||||
final player = _player;
|
final player = _player;
|
||||||
if (player != null) {
|
if (player != null) {
|
||||||
_mediaControls?.updatePlaybackState(
|
_mediaControls?.updatePlaybackState(
|
||||||
isPlaying: player.state.isActive,
|
isPlaying: shouldBePlaying && player.state.isActive,
|
||||||
position: player.currentPosition,
|
position: player.currentPosition,
|
||||||
speed: 1.0,
|
speed: 1.0,
|
||||||
force: true,
|
force: true,
|
||||||
@@ -629,7 +651,12 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
_currentSource = adopted.source;
|
_currentSource = adopted.source;
|
||||||
_consecutiveFailures = 0;
|
_consecutiveFailures = 0;
|
||||||
appLogger.d('Music: transition received "${adopted.track.title}" → cursor ${_queue.cursor}');
|
appLogger.d('Music: transition received "${adopted.track.title}" → cursor ${_queue.cursor}');
|
||||||
_setStatus(MusicPlaybackStatus.playing, forceNotify: true);
|
if (automotivePlaybackAllowedNow()) {
|
||||||
|
_setStatus(MusicPlaybackStatus.playing, forceNotify: true);
|
||||||
|
} else {
|
||||||
|
unawaited(_player?.pause());
|
||||||
|
_setStatus(MusicPlaybackStatus.paused, forceNotify: true);
|
||||||
|
}
|
||||||
_bindTrackServices(_currentTrack!, adopted.source);
|
_bindTrackServices(_currentTrack!, adopted.source);
|
||||||
_requestArmNext();
|
_requestArmNext();
|
||||||
}
|
}
|
||||||
@@ -808,11 +835,15 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OS transport commands. Music has no authorization gate: the session only
|
/// OS transport commands. Music has no authorization gate for playback: the
|
||||||
/// exists while a track is loaded, and that is checked in [_onControlEvent].
|
/// session only exists while a track is loaded, and that is checked in
|
||||||
|
/// [_onControlEvent]. The automotive gate deliberately does NOT sit on
|
||||||
|
/// [MediaControlRouter.canControlPlayback] — the router consumes a denied
|
||||||
|
/// event, so gating it there would swallow `PauseEvent` and leave the OS
|
||||||
|
/// unable to stop audio. Starting audio is gated inside [play] instead.
|
||||||
late final _mediaControlRouter = MediaControlRouter(
|
late final _mediaControlRouter = MediaControlRouter(
|
||||||
canControlPlayback: () => true,
|
canControlPlayback: () => true,
|
||||||
canNavigateMediaItems: () => true,
|
canNavigateMediaItems: automotivePlaybackAllowedNow,
|
||||||
onPlay: () => unawaited(play()),
|
onPlay: () => unawaited(play()),
|
||||||
onPause: () => unawaited(pause()),
|
onPause: () => unawaited(pause()),
|
||||||
onTogglePlayPause: () => unawaited(togglePlayPause()),
|
onTogglePlayPause: () => unawaited(togglePlayPause()),
|
||||||
@@ -873,6 +904,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> play() async {
|
Future<void> play() async {
|
||||||
|
if (!automotivePlaybackAllowedNow()) {
|
||||||
|
appLogger.d('Music play denied while automotive playback is restricted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
final player = _player;
|
final player = _player;
|
||||||
if (player == null || _currentTrack == null) return;
|
if (player == null || _currentTrack == null) return;
|
||||||
final generation = _generation;
|
final generation = _generation;
|
||||||
@@ -887,9 +922,23 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
}
|
}
|
||||||
_requestArmNext();
|
_requestArmNext();
|
||||||
}
|
}
|
||||||
|
if (!automotivePlaybackAllowedNow()) {
|
||||||
|
appLogger.d('Music play denied while automotive playback is restricted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await player.play();
|
await player.play();
|
||||||
if (!_isCurrentTransport(player, generation)) return;
|
if (!_isCurrentTransport(player, generation)) return;
|
||||||
|
if (!automotivePlaybackAllowedNow()) {
|
||||||
|
await player.pause();
|
||||||
|
if (_isCurrentTransport(player, generation)) {
|
||||||
|
_setStatus(MusicPlaybackStatus.paused);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
_setStatus(MusicPlaybackStatus.playing);
|
_setStatus(MusicPlaybackStatus.playing);
|
||||||
|
// A restriction cleared the native arm on the way in; restore it so gapless
|
||||||
|
// playback survives a park-and-resume cycle.
|
||||||
|
if (PlatformDetector.isAutomotive()) _requestArmNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -913,17 +962,36 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
return player.state.isActive ? pause() : play();
|
return player.state.isActive ? pause() : play();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apple TV only (observer registered in the constructor): pause when the
|
/// On Apple TV, pause when the app leaves the foreground because tvOS
|
||||||
/// app leaves the foreground — tvOS background audio is not attempted in
|
/// background audio is not attempted in v1. On AAOS, stop audio whenever
|
||||||
/// v1, so playback must not continue under the home screen.
|
/// the app is not resumed to comply with driver-distraction rule DD-2.
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) {
|
if (PlatformDetector.isAutomotive()) {
|
||||||
if (isPlaying) {
|
if (!automotivePlaybackAllowedNow()) {
|
||||||
appLogger.d('App backgrounded on Apple TV — pausing music playback');
|
_invalidateArmRequests();
|
||||||
unawaited(pause());
|
_rememberStaleArm();
|
||||||
|
final player = _player;
|
||||||
|
if (player != null) {
|
||||||
|
unawaited(_trySetNext(player, null));
|
||||||
|
}
|
||||||
|
if (isPlaying) {
|
||||||
|
appLogger.d('App restricted on Android Automotive — pausing music playback');
|
||||||
|
unawaited(pause());
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// Restrictions lifted: re-arm the next track that was cleared on entry.
|
||||||
|
// Playback itself stays paused until the user asks for it.
|
||||||
|
if (_currentTrack != null) _requestArmNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (PlatformDetector.isAppleTV() &&
|
||||||
|
(state == AppLifecycleState.paused || state == AppLifecycleState.hidden) &&
|
||||||
|
isPlaying) {
|
||||||
|
appLogger.d('App backgrounded on Apple TV — pausing music playback');
|
||||||
|
unawaited(pause());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,14 +169,13 @@ class _AppLocalePref extends Pref<AppLocale> {
|
|||||||
Future<void> writeTo(BaseSharedPreferencesService svc, AppLocale value) => svc.writeString(key, value.name);
|
Future<void> writeTo(BaseSharedPreferencesService svc, AppLocale value) => svc.writeString(key, value.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mobile-only with a macOS-disabled-by-default rule; forced off on TV and non-mobile platforms.
|
/// Uses a macOS-disabled default and is forced off when [PlatformDetector] disables PiP.
|
||||||
class _AutoPipPref extends Pref<bool> {
|
class _AutoPipPref extends Pref<bool> {
|
||||||
const _AutoPipPref() : super('auto_pip');
|
const _AutoPipPref() : super('auto_pip');
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool readFrom(BaseSharedPreferencesService svc) {
|
bool readFrom(BaseSharedPreferencesService svc) {
|
||||||
if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) return false;
|
if (!PlatformDetector.supportsPictureInPicture()) return false;
|
||||||
if (PlatformDetector.isTV()) return false;
|
|
||||||
return svc.prefs.getBool(key) ?? !Platform.isMacOS;
|
return svc.prefs.getBool(key) ?? !Platform.isMacOS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class OrientationHelper {
|
|||||||
/// This should be called when leaving full-screen experiences like
|
/// This should be called when leaving full-screen experiences like
|
||||||
/// the video player to restore the app's default orientation behavior.
|
/// the video player to restore the app's default orientation behavior.
|
||||||
static void restoreDefaultOrientations(BuildContext context) {
|
static void restoreDefaultOrientations(BuildContext context) {
|
||||||
|
if (PlatformDetector.isAutomotive()) return;
|
||||||
final isPhone = PlatformDetector.isPhone(context);
|
final isPhone = PlatformDetector.isPhone(context);
|
||||||
|
|
||||||
if (isPhone) {
|
if (isPhone) {
|
||||||
@@ -30,6 +31,7 @@ class OrientationHelper {
|
|||||||
/// Used by the video player to force landscape orientation during playback.
|
/// Used by the video player to force landscape orientation during playback.
|
||||||
static void setLandscapeOrientation() {
|
static void setLandscapeOrientation() {
|
||||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||||
|
if (PlatformDetector.isAutomotive()) return;
|
||||||
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
|
SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,20 @@ const _androidFeatureTelevision = 'android.hardware.type.television';
|
|||||||
const _androidFeatureLeanback = 'android.software.leanback';
|
const _androidFeatureLeanback = 'android.software.leanback';
|
||||||
const _androidFeatureFireTv = 'amazon.hardware.fire_tv';
|
const _androidFeatureFireTv = 'amazon.hardware.fire_tv';
|
||||||
const _androidFeatureTouchscreen = 'android.hardware.touchscreen';
|
const _androidFeatureTouchscreen = 'android.hardware.touchscreen';
|
||||||
|
const _androidFeatureAutomotive = 'android.hardware.type.automotive';
|
||||||
|
|
||||||
class AndroidTvFeatureDetection {
|
class AndroidTvFeatureDetection {
|
||||||
final bool isTv;
|
final bool isTv;
|
||||||
|
|
||||||
|
/// True on Android Automotive OS head units. Never true together with
|
||||||
|
/// [isTv]: `FEATURE_AUTOMOTIVE` is authoritative for the car form factor.
|
||||||
|
final bool isAutomotive;
|
||||||
|
|
||||||
|
/// Diagnostic TV signals, surfaced in the log export only while TV mode is
|
||||||
|
/// active. Non-empty with [isTv] false when automotive vetoed the verdict.
|
||||||
final List<String> reasons;
|
final List<String> reasons;
|
||||||
|
|
||||||
const AndroidTvFeatureDetection({required this.isTv, required this.reasons});
|
const AndroidTvFeatureDetection({required this.isTv, required this.isAutomotive, required this.reasons});
|
||||||
}
|
}
|
||||||
|
|
||||||
AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable<String> features) {
|
AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable<String> features) {
|
||||||
@@ -28,7 +36,16 @@ AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable<String> fea
|
|||||||
if (featureSet.contains(_androidFeatureFireTv)) reasons.add('fire_tv');
|
if (featureSet.contains(_androidFeatureFireTv)) reasons.add('fire_tv');
|
||||||
if (featureSet.isNotEmpty && !featureSet.contains(_androidFeatureTouchscreen)) reasons.add('no_touchscreen');
|
if (featureSet.isNotEmpty && !featureSet.contains(_androidFeatureTouchscreen)) reasons.add('no_touchscreen');
|
||||||
|
|
||||||
return AndroidTvFeatureDetection(isTv: reasons.isNotEmpty, reasons: reasons);
|
// A car is never a TV. Rotary-only head units report no touchscreen, and OEM
|
||||||
|
// images derived from other AOSP variants can carry a stray leanback flag;
|
||||||
|
// either would otherwise route a vehicle through the leanback experience.
|
||||||
|
final isAutomotive = featureSet.contains(_androidFeatureAutomotive);
|
||||||
|
|
||||||
|
return AndroidTvFeatureDetection(
|
||||||
|
isTv: !isAutomotive && reasons.isNotEmpty,
|
||||||
|
isAutomotive: isAutomotive,
|
||||||
|
reasons: reasons,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Service for detecting if the app is running on Android TV or Apple TV.
|
/// Service for detecting if the app is running on Android TV or Apple TV.
|
||||||
@@ -37,10 +54,12 @@ class TvDetectionService {
|
|||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static set debugDetectionGate(Future<void>? value) => _singleton.debugGate = value;
|
static set debugDetectionGate(Future<void>? value) => _singleton.debugGate = value;
|
||||||
static bool? _debugAppleTVOverride;
|
static bool? _debugAppleTVOverride;
|
||||||
|
static bool? _debugAutomotiveOverride;
|
||||||
bool _detected = false;
|
bool _detected = false;
|
||||||
bool _forceTv = false;
|
bool _forceTv = false;
|
||||||
bool _isTV = false;
|
bool _isTV = false;
|
||||||
bool _isAppleTV = false;
|
bool _isAppleTV = false;
|
||||||
|
bool _isAutomotive = false;
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
List<String> _detectionReasons = const [];
|
List<String> _detectionReasons = const [];
|
||||||
|
|
||||||
@@ -62,6 +81,7 @@ class TvDetectionService {
|
|||||||
final detection =
|
final detection =
|
||||||
nativeDetection ?? detectAndroidTvFromSystemFeatures((await deviceInfo.androidInfo).systemFeatures);
|
nativeDetection ?? detectAndroidTvFromSystemFeatures((await deviceInfo.androidInfo).systemFeatures);
|
||||||
_detected = detection.isTv;
|
_detected = detection.isTv;
|
||||||
|
_isAutomotive = detection.isAutomotive;
|
||||||
_detectionReasons = detection.reasons;
|
_detectionReasons = detection.reasons;
|
||||||
} else if (Platform.isIOS) {
|
} else if (Platform.isIOS) {
|
||||||
if (_tvosBuild) {
|
if (_tvosBuild) {
|
||||||
@@ -91,6 +111,10 @@ class TvDetectionService {
|
|||||||
|
|
||||||
bool get isTV => _isTV;
|
bool get isTV => _isTV;
|
||||||
|
|
||||||
|
/// True on Android Automotive OS. Independent of the force-TV override so
|
||||||
|
/// driver-distraction gating cannot be switched off from settings.
|
||||||
|
bool get isAutomotive => _isAutomotive;
|
||||||
|
|
||||||
List<String> get _effectiveDetectionReasons {
|
List<String> get _effectiveDetectionReasons {
|
||||||
final reasons = <String>[..._detectionReasons];
|
final reasons = <String>[..._detectionReasons];
|
||||||
if (_forceTv && !reasons.contains('force_tv')) reasons.add('force_tv');
|
if (_forceTv && !reasons.contains('force_tv')) reasons.add('force_tv');
|
||||||
@@ -104,8 +128,9 @@ class TvDetectionService {
|
|||||||
final reasonsValue = result['reasons'];
|
final reasonsValue = result['reasons'];
|
||||||
final reasons = reasonsValue is Iterable ? reasonsValue.whereType<String>().toList() : <String>[];
|
final reasons = reasonsValue is Iterable ? reasonsValue.whereType<String>().toList() : <String>[];
|
||||||
final isTv = result['isTv'] == true;
|
final isTv = result['isTv'] == true;
|
||||||
|
final isAutomotive = result['isAutomotive'] == true;
|
||||||
if (isTv && reasons.isEmpty) reasons.add('native');
|
if (isTv && reasons.isEmpty) reasons.add('native');
|
||||||
return AndroidTvFeatureDetection(isTv: isTv, reasons: reasons);
|
return AndroidTvFeatureDetection(isTv: isTv && !isAutomotive, isAutomotive: isAutomotive, reasons: reasons);
|
||||||
} on MissingPluginException {
|
} on MissingPluginException {
|
||||||
return null;
|
return null;
|
||||||
} on PlatformException {
|
} on PlatformException {
|
||||||
@@ -139,15 +164,24 @@ class TvDetectionService {
|
|||||||
/// Synchronous Apple TV check (returns false if not initialized or not tvOS).
|
/// Synchronous Apple TV check (returns false if not initialized or not tvOS).
|
||||||
static bool isAppleTVSync() => _debugAppleTVOverride ?? (_tvosBuild || _singleton.instance?._isAppleTV == true);
|
static bool isAppleTVSync() => _debugAppleTVOverride ?? (_tvosBuild || _singleton.instance?._isAppleTV == true);
|
||||||
|
|
||||||
|
/// Synchronous Android Automotive OS check (false before initialization).
|
||||||
|
static bool isAutomotiveSync() => _debugAutomotiveOverride ?? _singleton.instance?._isAutomotive ?? false;
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static void debugSetAppleTVOverride(bool? value) {
|
static void debugSetAppleTVOverride(bool? value) {
|
||||||
_debugAppleTVOverride = value;
|
_debugAppleTVOverride = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static void debugSetAutomotiveOverride(bool? value) {
|
||||||
|
_debugAutomotiveOverride = value;
|
||||||
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static void debugReset() {
|
static void debugReset() {
|
||||||
_singleton.debugReset();
|
_singleton.debugReset();
|
||||||
_debugAppleTVOverride = null;
|
_debugAppleTVOverride = null;
|
||||||
|
_debugAutomotiveOverride = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<String> tvDetectionReasonsSync() => _singleton.instance?._effectiveDetectionReasons ?? const [];
|
static List<String> tvDetectionReasonsSync() => _singleton.instance?._effectiveDetectionReasons ?? const [];
|
||||||
@@ -165,6 +199,11 @@ class PlatformDetector {
|
|||||||
return TvDetectionService.isAppleTVSync();
|
return TvDetectionService.isAppleTVSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True on Android Automotive OS head units.
|
||||||
|
static bool isAutomotive() {
|
||||||
|
return TvDetectionService.isAutomotiveSync();
|
||||||
|
}
|
||||||
|
|
||||||
/// Detects if the app should use side navigation (Desktop or TV)
|
/// Detects if the app should use side navigation (Desktop or TV)
|
||||||
static bool shouldUseSideNavigation(BuildContext context) {
|
static bool shouldUseSideNavigation(BuildContext context) {
|
||||||
return isDesktop(context) || isTV();
|
return isDesktop(context) || isTV();
|
||||||
@@ -229,7 +268,9 @@ class PlatformDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static bool supportsPictureInPicture() {
|
static bool supportsPictureInPicture() {
|
||||||
return !isAppleTV() && !isTV() && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
|
// Cars commonly lack FEATURE_PICTURE_IN_PICTURE, and a floating player
|
||||||
|
// would keep the app's UI on screen while driving, which `DD-2` forbids.
|
||||||
|
return !isAppleTV() && !isTV() && !isAutomotive() && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detects if the device is likely a tablet based on screen size
|
/// Detects if the device is likely a tablet based on screen size
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState {
|
|||||||
/// [SettingsService.rotationLocked] via [bindEffect] so any change — from
|
/// [SettingsService.rotationLocked] via [bindEffect] so any change — from
|
||||||
/// this toggle or from the settings screen — fires the same SystemChrome call.
|
/// this toggle or from the settings screen — fires the same SystemChrome call.
|
||||||
void _applyRotationLock(bool locked) {
|
void _applyRotationLock(bool locked) {
|
||||||
|
if (PlatformDetector.isAutomotive()) return;
|
||||||
unawaited(
|
unawaited(
|
||||||
SystemChrome.setPreferredOrientations(
|
SystemChrome.setPreferredOrientations(
|
||||||
locked ? const [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight] : DeviceOrientation.values,
|
locked ? const [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight] : DeviceOrientation.values,
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:os_media_controls/os_media_controls.dart';
|
||||||
|
import 'package:plezy/screens/video_player_screen.dart';
|
||||||
|
import 'package:plezy/services/driver_distraction.dart';
|
||||||
|
import 'package:plezy/services/media_control_router.dart';
|
||||||
|
import 'package:plezy/utils/platform_detector.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(TvDetectionService.debugReset);
|
||||||
|
tearDown(TvDetectionService.debugReset);
|
||||||
|
|
||||||
|
test('automotive background policy pauses independently of handheld and TV detection', () {
|
||||||
|
expect(shouldPauseVideoForBackground(isHandheld: false, isTv: false, isAutomotive: true), isTrue);
|
||||||
|
expect(shouldPauseVideoForBackground(isHandheld: true, isTv: false, isAutomotive: false), isTrue);
|
||||||
|
expect(shouldPauseVideoForBackground(isHandheld: false, isTv: true, isAutomotive: false), isTrue);
|
||||||
|
expect(shouldPauseVideoForBackground(isHandheld: false, isTv: false, isAutomotive: false), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('automotive playback is allowed only while resumed', () {
|
||||||
|
expect(automotivePlaybackAllowed(isAutomotive: true, state: AppLifecycleState.resumed), isTrue);
|
||||||
|
expect(automotivePlaybackAllowed(isAutomotive: true, state: AppLifecycleState.inactive), isFalse);
|
||||||
|
expect(automotivePlaybackAllowed(isAutomotive: true, state: null), isFalse);
|
||||||
|
expect(automotivePlaybackAllowed(isAutomotive: false, state: AppLifecycleState.inactive), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('automotive media controls block navigation but never swallow pause', (tester) async {
|
||||||
|
addTearDown(() => tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(true);
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
|
||||||
|
final calls = <String>[];
|
||||||
|
// Mirrors the production wiring in playback_services.dart: playback
|
||||||
|
// authority is Watch Together's alone, and only the navigation gate carries
|
||||||
|
// the automotive requirement. Starting playback is refused downstream by
|
||||||
|
// the playback-intent wrappers, not here — `route` consumes a denied event,
|
||||||
|
// so gating `canControlPlayback` would silently drop `PauseEvent`.
|
||||||
|
final router = MediaControlRouter(
|
||||||
|
canControlPlayback: () => true,
|
||||||
|
canNavigateMediaItems: automotivePlaybackAllowedNow,
|
||||||
|
onPlay: () => calls.add('play'),
|
||||||
|
onPause: () => calls.add('pause'),
|
||||||
|
onTogglePlayPause: () => calls.add('toggle'),
|
||||||
|
onSeek: (_) => calls.add('seek'),
|
||||||
|
onNext: () => calls.add('next'),
|
||||||
|
onPrevious: () => calls.add('previous'),
|
||||||
|
onStop: () => calls.add('stop'),
|
||||||
|
onSkipForward: (_) => calls.add('forward'),
|
||||||
|
onSkipBackward: (_) => calls.add('backward'),
|
||||||
|
onSetSpeed: (_) => calls.add('speed'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stopping audio must stay reachable while the vehicle restricts the app.
|
||||||
|
expect(router.route(const PauseEvent()), isTrue);
|
||||||
|
expect(router.route(const StopEvent()), isTrue);
|
||||||
|
expect(calls, ['pause', 'stop']);
|
||||||
|
|
||||||
|
// Queue navigation starts audio, so it is refused while restricted.
|
||||||
|
expect(router.route(const NextTrackEvent()), isTrue);
|
||||||
|
expect(router.route(const PreviousTrackEvent()), isTrue);
|
||||||
|
expect(calls, ['pause', 'stop']);
|
||||||
|
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||||
|
expect(router.route(const NextTrackEvent()), isTrue);
|
||||||
|
expect(calls, ['pause', 'stop', 'next']);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/services/driver_distraction.dart';
|
||||||
|
import 'package:plezy/utils/platform_detector.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('automotivePlaybackAllowed', () {
|
||||||
|
test('never restricts playback off Android Automotive OS', () {
|
||||||
|
for (final state in [...AppLifecycleState.values, null]) {
|
||||||
|
expect(
|
||||||
|
automotivePlaybackAllowed(isAutomotive: false, state: state),
|
||||||
|
isTrue,
|
||||||
|
reason: 'non-automotive playback must not depend on lifecycle state ($state)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows playback on a car only while the app is resumed', () {
|
||||||
|
expect(automotivePlaybackAllowed(isAutomotive: true, state: AppLifecycleState.resumed), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stops playback on the onPause edge that every car delivers', () {
|
||||||
|
// Android `onPause` maps to `inactive`, and cars without the Automotive
|
||||||
|
// compatibility mode never go on to deliver `onStop`. This is the edge
|
||||||
|
// the rejected build ignored.
|
||||||
|
expect(automotivePlaybackAllowed(isAutomotive: true, state: AppLifecycleState.inactive), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stops playback on the onStop edges compatibility-mode cars deliver', () {
|
||||||
|
expect(automotivePlaybackAllowed(isAutomotive: true, state: AppLifecycleState.hidden), isFalse);
|
||||||
|
expect(automotivePlaybackAllowed(isAutomotive: true, state: AppLifecycleState.paused), isFalse);
|
||||||
|
expect(automotivePlaybackAllowed(isAutomotive: true, state: AppLifecycleState.detached), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails closed on an unknown lifecycle state', () {
|
||||||
|
expect(automotivePlaybackAllowed(isAutomotive: true, state: null), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('automotivePlaybackAllowedNow', () {
|
||||||
|
setUp(() {
|
||||||
|
TvDetectionService.debugReset();
|
||||||
|
addTearDown(TvDetectionService.debugReset);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reads the ambient form factor and lifecycle', (tester) async {
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||||
|
expect(automotivePlaybackAllowedNow(), isTrue);
|
||||||
|
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(true);
|
||||||
|
expect(automotivePlaybackAllowedNow(), isTrue);
|
||||||
|
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
expect(automotivePlaybackAllowedNow(), isFalse);
|
||||||
|
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(false);
|
||||||
|
expect(automotivePlaybackAllowedNow(), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:os_media_controls/os_media_controls.dart';
|
||||||
|
import 'package:plezy/media/media_backend.dart';
|
||||||
|
import 'package:plezy/media/media_item.dart';
|
||||||
|
import 'package:plezy/media/media_kind.dart';
|
||||||
|
import 'package:plezy/services/multi_server_manager.dart';
|
||||||
|
import 'package:plezy/services/music/music_playback_service.dart';
|
||||||
|
import 'package:plezy/services/music/music_playback_service_impl.dart';
|
||||||
|
import 'package:plezy/utils/notification_permission.dart';
|
||||||
|
import 'package:plezy/utils/platform_detector.dart';
|
||||||
|
|
||||||
|
import '../../test_helpers/media_items.dart';
|
||||||
|
import 'music_playback_service_test.dart' as music_fakes;
|
||||||
|
|
||||||
|
MediaItem _track(String id) => testMediaItem(
|
||||||
|
id: id,
|
||||||
|
backend: MediaBackend.plex,
|
||||||
|
kind: MediaKind.track,
|
||||||
|
title: 'Track $id',
|
||||||
|
durationMs: const Duration(minutes: 3).inMilliseconds,
|
||||||
|
serverId: 'srv',
|
||||||
|
);
|
||||||
|
|
||||||
|
class _RecordingMediaControlsManager extends music_fakes.FakeMediaControlsManager {
|
||||||
|
final List<bool> backgroundModeCalls = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setBackgroundMode(bool enabled) async {
|
||||||
|
backgroundModeCalls.add(enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Harness {
|
||||||
|
_Harness._(this.service, this.controls, this.players, this.serverManager);
|
||||||
|
|
||||||
|
final MusicPlaybackServiceImpl service;
|
||||||
|
final _RecordingMediaControlsManager controls;
|
||||||
|
final List<music_fakes.FakePlayer> players;
|
||||||
|
final MultiServerManager serverManager;
|
||||||
|
|
||||||
|
music_fakes.FakePlayer get player => players.single;
|
||||||
|
|
||||||
|
factory _Harness.create() {
|
||||||
|
final controls = _RecordingMediaControlsManager();
|
||||||
|
final players = <music_fakes.FakePlayer>[];
|
||||||
|
final serverManager = MultiServerManager();
|
||||||
|
final service = MusicPlaybackServiceImpl(
|
||||||
|
serverManager: serverManager,
|
||||||
|
resolver: music_fakes.FakeMusicSourceResolver(),
|
||||||
|
audioPlayerFactory: () {
|
||||||
|
final player = music_fakes.FakePlayer();
|
||||||
|
players.add(player);
|
||||||
|
return player;
|
||||||
|
},
|
||||||
|
mediaControlsFactory: () => controls,
|
||||||
|
volumePersistenceWriter: (_) async {},
|
||||||
|
);
|
||||||
|
return _Harness._(service, controls, players, serverManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> start(List<MediaItem> tracks) async {
|
||||||
|
await service.playFromList(
|
||||||
|
tracks: tracks,
|
||||||
|
playContext: const MusicPlayContext(title: 'Test', kind: MusicPlayContextKind.album),
|
||||||
|
);
|
||||||
|
await pumpEventQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
service.dispose();
|
||||||
|
for (final player in players) {
|
||||||
|
player.closeControllers();
|
||||||
|
}
|
||||||
|
controls.closeControllers();
|
||||||
|
serverManager.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
// A real binding is required to drive lifecycle state, but the bodies below
|
||||||
|
// stay plain `test()` so timers and `pumpEventQueue()` run on the real async
|
||||||
|
// queue — inside `testWidgets` the fake-async zone never drains them.
|
||||||
|
final binding = TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
setUp(TvDetectionService.debugReset);
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
TvDetectionService.debugReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('play is refused while automotive lifecycle is not resumed', () async {
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(true);
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
final harness = _Harness.create();
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
addTearDown(() => binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
|
||||||
|
|
||||||
|
await harness.start([_track('one')]);
|
||||||
|
expect(harness.service.status, MusicPlaybackStatus.paused);
|
||||||
|
expect(harness.player.state.playing, isFalse);
|
||||||
|
|
||||||
|
await harness.service.play();
|
||||||
|
|
||||||
|
expect(harness.player.playCalls, 0);
|
||||||
|
expect(harness.player.state.playing, isFalse);
|
||||||
|
expect(harness.service.status, MusicPlaybackStatus.paused);
|
||||||
|
expect(harness.player.setNextCalls.where((media) => media != null), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media-session play is refused while automotive lifecycle is not resumed', () async {
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(true);
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
final harness = _Harness.create();
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
addTearDown(() => binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
|
||||||
|
await harness.start([_track('one')]);
|
||||||
|
|
||||||
|
harness.controls.eventsCtrl.add(const PlayEvent());
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(harness.player.playCalls, 0);
|
||||||
|
expect(harness.player.state.playing, isFalse);
|
||||||
|
expect(harness.service.status, MusicPlaybackStatus.paused);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('background mode is explicitly disabled on automotive', () async {
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(true);
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
final harness = _Harness.create();
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
addTearDown(() => binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
|
||||||
|
|
||||||
|
await harness.start([_track('one')]);
|
||||||
|
|
||||||
|
expect(harness.controls.backgroundModeCalls, [false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('playback remains unrestricted off automotive', () async {
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(false);
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
final harness = _Harness.create();
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
addTearDown(() => binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
|
||||||
|
|
||||||
|
await harness.start([_track('one')]);
|
||||||
|
expect(harness.player.state.playing, isTrue);
|
||||||
|
expect(harness.service.status, MusicPlaybackStatus.playing);
|
||||||
|
expect(harness.controls.backgroundModeCalls, [true]);
|
||||||
|
|
||||||
|
await harness.service.pause();
|
||||||
|
await harness.service.play();
|
||||||
|
|
||||||
|
expect(harness.player.playCalls, 1);
|
||||||
|
expect(harness.player.state.playing, isTrue);
|
||||||
|
expect(harness.service.status, MusicPlaybackStatus.playing);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('automotive lifecycle restriction pauses and clears the gapless arm', () async {
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(true);
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||||
|
final harness = _Harness.create();
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
addTearDown(() => binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
|
||||||
|
final second = _track('two');
|
||||||
|
await harness.start([_track('one'), second]);
|
||||||
|
expect(harness.player.armed?.uri, 'fake://${second.id}');
|
||||||
|
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(harness.player.pauseCalls, greaterThanOrEqualTo(1));
|
||||||
|
expect(harness.player.armed, isNull);
|
||||||
|
expect(harness.player.state.playing, isFalse);
|
||||||
|
expect(harness.service.status, MusicPlaybackStatus.paused);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a transition racing the automotive pause is adopted but cannot keep playing', () async {
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(true);
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||||
|
final harness = _Harness.create();
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
addTearDown(() => binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
|
||||||
|
final second = _track('two');
|
||||||
|
await harness.start([_track('one'), second]);
|
||||||
|
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
harness.player.emitTransition('fake://${second.id}');
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(harness.service.currentTrack?.id, second.id);
|
||||||
|
expect(harness.service.status, MusicPlaybackStatus.paused);
|
||||||
|
expect(harness.player.state.playing, isFalse);
|
||||||
|
expect(harness.player.armed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media-session stop remains unconditional while automotive playback is restricted', () async {
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(true);
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
final harness = _Harness.create();
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
addTearDown(() => binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
|
||||||
|
await harness.start([_track('one')]);
|
||||||
|
|
||||||
|
harness.controls.eventsCtrl.add(const StopEvent());
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(harness.service.status, MusicPlaybackStatus.idle);
|
||||||
|
expect(harness.service.currentTrack, isNull);
|
||||||
|
expect(harness.player.stopCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the gapless arm is restored once automotive restrictions lift', () async {
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(true);
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||||
|
final harness = _Harness.create();
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
addTearDown(() => binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
|
||||||
|
final second = _track('two');
|
||||||
|
await harness.start([_track('one'), second]);
|
||||||
|
expect(harness.player.armed?.uri, 'fake://${second.id}');
|
||||||
|
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(harness.player.armed, isNull);
|
||||||
|
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
// Gapless playback survives the park-and-resume cycle, but parking must not
|
||||||
|
// restart audio on its own.
|
||||||
|
expect(harness.player.armed?.uri, 'fake://${second.id}');
|
||||||
|
expect(harness.service.status, MusicPlaybackStatus.paused);
|
||||||
|
expect(harness.player.state.playing, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('media-session pause still stops audio while automotive playback is restricted', () async {
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(true);
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||||
|
final harness = _Harness.create();
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
addTearDown(() => binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
|
||||||
|
await harness.start([_track('one')]);
|
||||||
|
expect(harness.player.state.playing, isTrue);
|
||||||
|
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
await pumpEventQueue();
|
||||||
|
final pausesAfterRestriction = harness.player.pauseCalls;
|
||||||
|
|
||||||
|
// The router consumes a denied event, so gating `canControlPlayback` would
|
||||||
|
// silently swallow this and leave the OS unable to stop audio.
|
||||||
|
harness.controls.eventsCtrl.add(const PauseEvent());
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(harness.player.pauseCalls, greaterThan(pausesAfterRestriction));
|
||||||
|
expect(harness.service.status, MusicPlaybackStatus.paused);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('automotive never prompts for notifications, so first play keeps its intent', () async {
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(true);
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||||
|
NotificationPermission.debugReset();
|
||||||
|
addTearDown(NotificationPermission.debugReset);
|
||||||
|
addTearDown(() => NotificationPermission.debugRequestOverride = null);
|
||||||
|
addTearDown(() => binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed));
|
||||||
|
|
||||||
|
var prompts = 0;
|
||||||
|
// A real prompt takes focus, which would leave the app not resumed and make
|
||||||
|
// the gate open the track paused, silently dropping the user's play intent.
|
||||||
|
NotificationPermission.debugRequestOverride = () async {
|
||||||
|
prompts++;
|
||||||
|
binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
|
||||||
|
};
|
||||||
|
|
||||||
|
final harness = _Harness.create();
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
await harness.start([_track('one')]);
|
||||||
|
|
||||||
|
// Nothing to authorize: the foreground service never starts on a car.
|
||||||
|
expect(prompts, 0);
|
||||||
|
expect(harness.controls.backgroundModeCalls, [false]);
|
||||||
|
expect(harness.player.state.playing, isTrue);
|
||||||
|
expect(harness.service.status, MusicPlaybackStatus.playing);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('other platforms still request the notification permission', () async {
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(false);
|
||||||
|
NotificationPermission.debugReset();
|
||||||
|
addTearDown(NotificationPermission.debugReset);
|
||||||
|
addTearDown(() => NotificationPermission.debugRequestOverride = null);
|
||||||
|
|
||||||
|
var prompts = 0;
|
||||||
|
NotificationPermission.debugRequestOverride = () async => prompts++;
|
||||||
|
|
||||||
|
final harness = _Harness.create();
|
||||||
|
addTearDown(harness.dispose);
|
||||||
|
await harness.start([_track('one')]);
|
||||||
|
await pumpEventQueue();
|
||||||
|
|
||||||
|
expect(prompts, 1);
|
||||||
|
expect(harness.controls.backgroundModeCalls, [true]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ void main() {
|
|||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
TvDetectionService.debugSetAppleTVOverride(null);
|
TvDetectionService.debugSetAppleTVOverride(null);
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('SettingsService.parseMpvConfigText', () {
|
group('SettingsService.parseMpvConfigText', () {
|
||||||
@@ -230,6 +231,17 @@ void main() {
|
|||||||
|
|
||||||
expect(settings.read(SettingsService.autoPip), isFalse);
|
expect(settings.read(SettingsService.autoPip), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('forces auto PiP off on automotive while honoring the stored value elsewhere', () async {
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
await settings.write(SettingsService.autoPip, true);
|
||||||
|
|
||||||
|
expect(settings.read(SettingsService.autoPip), isTrue);
|
||||||
|
|
||||||
|
TvDetectionService.debugSetAutomotiveOverride(true);
|
||||||
|
|
||||||
|
expect(settings.read(SettingsService.autoPip), isFalse);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('SettingsService companion remote prefs', () {
|
group('SettingsService companion remote prefs', () {
|
||||||
|
|||||||
@@ -77,5 +77,40 @@ void main() {
|
|||||||
expect(detection.isTv, isFalse);
|
expect(detection.isTv, isFalse);
|
||||||
expect(detection.reasons, isEmpty);
|
expect(detection.reasons, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('classifies automotive head units as cars, not TVs', () {
|
||||||
|
final detection = detectAndroidTvFromSystemFeatures([
|
||||||
|
'android.hardware.type.automotive',
|
||||||
|
'android.hardware.touchscreen',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(detection.isAutomotive, isTrue);
|
||||||
|
expect(detection.isTv, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rotary-only head units are cars despite reporting no touchscreen', () {
|
||||||
|
final detection = detectAndroidTvFromSystemFeatures(['android.hardware.type.automotive']);
|
||||||
|
|
||||||
|
expect(detection.isAutomotive, isTrue);
|
||||||
|
expect(detection.isTv, isFalse);
|
||||||
|
expect(detection.reasons, contains('no_touchscreen'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('automotive vetoes a stray leanback flag from an OEM image', () {
|
||||||
|
final detection = detectAndroidTvFromSystemFeatures([
|
||||||
|
'android.hardware.type.automotive',
|
||||||
|
'android.software.leanback',
|
||||||
|
'android.hardware.touchscreen',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(detection.isAutomotive, isTrue);
|
||||||
|
expect(detection.isTv, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary devices are not automotive', () {
|
||||||
|
final detection = detectAndroidTvFromSystemFeatures(['android.hardware.touchscreen']);
|
||||||
|
|
||||||
|
expect(detection.isAutomotive, isFalse);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user