diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index fed723c3..93501fa0 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -96,6 +96,8 @@ class MainActivity : FlutterActivity() { private fun isAndroidTvDevice(): Boolean = getAndroidTvDetection()["isTv"] as Boolean + private fun isPipSupportedDevice(): Boolean = !isAndroidTvDevice() && packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + private fun isImeVisible(): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false 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 hasTouchscreen = pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN) val hasFakeTouch = pm.hasSystemFeature(PackageManager.FEATURE_FAKETOUCH) + val isAutomotive = pm.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE) val reasons = mutableListOf() if (isTelevisionUiMode) reasons.add("ui_mode_television") @@ -177,7 +180,11 @@ class MainActivity : FlutterActivity() { if (!hasTouchscreen) reasons.add("no_touchscreen") 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, "isTelevisionUiMode" to isTelevisionUiMode, "hasTelevisionFeature" to hasTelevisionFeature, @@ -662,7 +669,7 @@ class MainActivity : FlutterActivity() { MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL).setMethodCallHandler { call, result -> when (call.method) { "isSupported" -> { - result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAndroidTvDevice()) + result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isPipSupportedDevice()) } "enter" -> { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { @@ -670,7 +677,7 @@ class MainActivity : FlutterActivity() { return@setMethodCallHandler } - if (isAndroidTvDevice()) { + if (!isPipSupportedDevice()) { result.success(mapOf("success" to false, "errorCode" to "not_supported")) return@setMethodCallHandler } @@ -697,7 +704,7 @@ class MainActivity : FlutterActivity() { } } "setAutoPipReady" -> { - if (isAndroidTvDevice()) { + if (!isPipSupportedDevice()) { autoPipReady = false result.success(true) return@setMethodCallHandler @@ -821,7 +828,7 @@ class MainActivity : FlutterActivity() { override fun onUserLeaveHint() { super.onUserLeaveHint() // 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.S && autoPipReady && diff --git a/android/app/src/main/kotlin/com/edde746/plezy/TvDetection.kt b/android/app/src/main/kotlin/com/edde746/plezy/TvDetection.kt index d1305947..898dca6e 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/TvDetection.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/TvDetection.kt @@ -5,15 +5,19 @@ import android.content.pm.PackageManager import android.content.res.Configuration /** - * Native mirror of MainActivity.getAndroidTvDetection(): any TV signal counts. - * Kept in sync with the Dart-facing detection so native gating matches - * PlatformDetector.isTV(). + * Native mirror of MainActivity.getAndroidTvDetection(): any TV signal counts, + * except that FEATURE_AUTOMOTIVE vetoes the verdict outright. Kept in sync with + * the Dart-facing detection so native gating matches PlatformDetector.isTV(). */ object TvDetection { fun isTv(context: Context): Boolean { val pm = context.packageManager 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") return uiModeType == Configuration.UI_MODE_TYPE_TELEVISION || pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) || diff --git a/lib/screens/video_player/parts/display_matching.dart b/lib/screens/video_player/parts/display_matching.dart index 5e2255fd..afc85a74 100644 --- a/lib/screens/video_player/parts/display_matching.dart +++ b/lib/screens/video_player/parts/display_matching.dart @@ -48,7 +48,7 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState { } if (mounted && player != null) { - await player!.play(); + await _playWithPlaybackIntent(player!); } unawaited( diff --git a/lib/screens/video_player/parts/lifecycle.dart b/lib/screens/video_player/parts/lifecycle.dart index c7a5c607..1b0fc544 100644 --- a/lib/screens/video_player/parts/lifecycle.dart +++ b/lib/screens/video_player/parts/lifecycle.dart @@ -1,5 +1,8 @@ part of '../../video_player_screen.dart'; +bool shouldPauseVideoForBackground({required bool isHandheld, required bool isTv, required bool isAutomotive}) => + isHandheld || isTv || isAutomotive; + extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { void _enqueueLifecycleTransition(String label, Future Function() transition) { _lifecycleTransition = _lifecycleTransition @@ -108,16 +111,32 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState { 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 // background surface while the app is locking or hiding. if (shouldPauseForBackground) { - _wasPlayingBeforeInactive = currentPlayer.state.isActive; - if (_wasPlayingBeforeInactive) { + // Sticky latch: a car with the Automotive compatibility mode delivers + // 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 { 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) { appLogger.w('Failed to pause video before background transition', error: e); } diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index 98bb67f3..5484cad9 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -121,8 +121,11 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { recover: () => session.recover(directStream: ds, directStreamAudio: dsa), lookupStreamUrl: (recovered) => recovered.streamUrlAt(), applyPlayerOptions: () => _setLiveStreamOptions(currentPlayer), - open: (streamUrl) => - currentPlayer.open(Media(streamUrl, headers: const {'Accept-Language': 'en'}), play: true, isLive: true), + open: (streamUrl) => currentPlayer.open( + Media(streamUrl, headers: const {'Accept-Language': 'en'}), + play: automotivePlaybackAllowedNow(), + isLive: true, + ), isCurrent: isCurrent, adoptSession: (recovered) { _live.adoptSession(recovered); @@ -198,7 +201,11 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { _live.playbackStartTime = DateTime.now(); 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(() {}); } @@ -305,7 +312,11 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { _hasRenderedFirstFrame = false; }); 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()) { _abandonLiveSession(session); return; diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 967b17ea..85358644 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -468,8 +468,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { _mediaControlsManager = mediaControlsManager; 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, - canNavigateMediaItems: _canNavigateMediaItems, + canNavigateMediaItems: () => _canNavigateMediaItems() && automotivePlaybackAllowedNow(), onPlay: () { final currentPlayer = player; if (currentPlayer == null) return; @@ -592,6 +595,24 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { _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) { appLogger.w('Playback started while Android TV background media controls are suspended; pausing'); Sentry.addBreadcrumb( diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index e0c24563..38100932 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -70,7 +70,11 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { } 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; _trackManager?.cacheExternalSubtitles(const []); @@ -86,6 +90,9 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { }); _trackManager?.mediaInfo = null; } + if (PlatformDetector.isAutomotive()) { + await _playWithPlaybackIntent(currentPlayer); + } } catch (e, st) { appLogger.e('Failed to start live TV playback', error: e, stackTrace: st); unawaited(_sendLiveTimeline('stopped')); @@ -257,7 +264,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { selectedVersion: result.selectedVersion, timing: openTiming, headers: streamHeaders, - play: shouldAutoPlay, + play: shouldAutoPlay && !PlatformDetector.isAutomotive(), externalSubtitlesAtOpen: externalSubtitlePlan.subtitlesAtOpen, shouldContinue: () => attempt.isCurrent, onMediaAvailabilityChanged: (available) => primaryMediaOpened = available, @@ -279,6 +286,10 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { _attachToWatchTogetherSession(startupHold: wtStartupHold?.future); _notifyWatchTogetherMediaChange(); } + if (shouldAutoPlay && PlatformDetector.isAutomotive()) { + await _playWithPlaybackIntent(currentPlayer); + if (!attempt.isCurrent) return; + } } else { externalSubtitlePlan = _prepareExternalSubtitleOpenPlan( player: currentPlayer, diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index d3086d9a..84fbde0f 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -40,6 +40,7 @@ import '../models/companion_remote/remote_command.dart'; import '../providers/companion_remote_provider.dart'; import '../services/companion_remote/companion_remote_receiver.dart'; import '../services/fullscreen_state_manager.dart'; +import '../services/driver_distraction.dart'; import '../services/discord_rpc_service.dart'; import '../services/trackers/tracker_coordinator.dart'; import '../services/trakt/trakt_scrobble_service.dart'; @@ -689,6 +690,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin } Future _playWithPlaybackIntent(Player currentPlayer) { + if (!automotivePlaybackAllowedNow()) { + _playbackIntentShouldPlay = false; + appLogger.d('Playback blocked while Android Automotive app is not resumed'); + return Future.value(); + } _playbackIntentShouldPlay = true; if (widget.isLive && _live.retryFailed) { if (_live.retrying) return Future.value(); @@ -710,6 +716,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin } Future _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) { return _playWithPlaybackIntent(currentPlayer); } @@ -867,6 +877,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin switch (state) { case AppLifecycleState.inactive: _recordLifecycleState('inactive'); + if (PlatformDetector.isAutomotive()) { + _enqueueLifecycleTransition('inactive_automotive', _handleAppHidden); + } break; case AppLifecycleState.hidden: _recordLifecycleState('hidden'); @@ -1463,6 +1476,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin 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 { if (_isPhone) { await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp, DeviceOrientation.portraitDown]); diff --git a/lib/services/driver_distraction.dart b/lib/services/driver_distraction.dart new file mode 100644 index 00000000..e65bc526 --- /dev/null +++ b/lib/services/driver_distraction.dart @@ -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); +} diff --git a/lib/services/music/music_playback_service_impl.dart b/lib/services/music/music_playback_service_impl.dart index a93a3e1d..e1f92c1f 100644 --- a/lib/services/music/music_playback_service_impl.dart +++ b/lib/services/music/music_playback_service_impl.dart @@ -14,6 +14,7 @@ import '../../mpv/player/player.dart'; import '../../utils/app_logger.dart'; import '../../utils/notification_permission.dart'; import '../../utils/platform_detector.dart'; +import '../driver_distraction.dart'; import '../media_control_router.dart'; import '../media_controls_manager.dart'; import '../multi_server_manager.dart'; @@ -74,10 +75,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO _coordinator = coordinator ?? PlaybackCoordinator.instance, _volumePersistenceWriter = volumePersistenceWriter ?? _writePersistedVolume { _coordinator.registerMusicSession(stopAndDispose: _stopForVideoClaim); - // tvOS has no background-audio session in v1 — pause on backgrounding so - // audio doesn't play over other apps / the home screen. Other platforms - // keep playing under their OS media session. - if (PlatformDetector.isAppleTV()) { + // tvOS has no background-audio session in v1, so it pauses on + // backgrounding. AAOS must stop audio while driving per DD-2. Other + // platforms keep playing under their OS media session. + if (PlatformDetector.isAppleTV() || PlatformDetector.isAutomotive()) { _observesLifecycle = true; WidgetsBinding.instance.addObserver(this); } @@ -287,7 +288,15 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO // Android 13+: the background playback notification needs // POST_NOTIFICATIONS. Fire-and-forget — playback and the foreground // 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; _invalidateArmRequests(); _finalizeCurrentTrack(); @@ -327,7 +336,8 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO // Re-asserted per open (cheap, idempotent): the native side drops the // background-mode opt-in when the user swipes the task away, so a // 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 // 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; + final shouldPlay = play && automotivePlaybackAllowedNow(); 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) { appLogger.w('Music open failed for ${track.id}', error: e, stackTrace: st); if (generation == _generation) _handlePlaybackFailure(e); @@ -367,8 +378,13 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO } if (generation != _generation || _player != player) return; + final playbackStarted = shouldPlay && automotivePlaybackAllowedNow(); + if (shouldPlay && !playbackStarted) { + await player.pause(); + if (generation != _generation || _player != player) return; + } committedPlayer = player; - _setStatus(play ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused); + _setStatus(playbackStarted ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused); _bindTrackServices(track, source); } finally { // 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() { - if (_disposed) return; + if (_disposed || !automotivePlaybackAllowedNow()) return; _armRequestGeneration++; _armRequestPending = true; _ensureArmDrain(); @@ -502,6 +518,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO } Future _trySetNext(Player player, Media? media) async { + if (media != null && !automotivePlaybackAllowedNow()) return false; try { await player.setNext(media); return true; @@ -559,14 +576,19 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO } void _onPlayingChanged(bool isPlaying) { + final playbackAllowed = automotivePlaybackAllowedNow(); + final shouldBePlaying = isPlaying && playbackAllowed; + if (isPlaying && !playbackAllowed) { + unawaited(_player?.pause()); + } if (_status == MusicPlaybackStatus.playing || _status == MusicPlaybackStatus.paused) { - _setStatus(isPlaying ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused); - unawaited(_tracker?.sendProgress(isPlaying ? 'playing' : 'paused')); + _setStatus(shouldBePlaying ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused); + unawaited(_tracker?.sendProgress(shouldBePlaying ? 'playing' : 'paused')); } final player = _player; if (player != null) { _mediaControls?.updatePlaybackState( - isPlaying: player.state.isActive, + isPlaying: shouldBePlaying && player.state.isActive, position: player.currentPosition, speed: 1.0, force: true, @@ -629,7 +651,12 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO _currentSource = adopted.source; _consecutiveFailures = 0; 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); _requestArmNext(); } @@ -808,11 +835,15 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO ); } - /// OS transport commands. Music has no authorization gate: the session only - /// exists while a track is loaded, and that is checked in [_onControlEvent]. + /// OS transport commands. Music has no authorization gate for playback: the + /// 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( canControlPlayback: () => true, - canNavigateMediaItems: () => true, + canNavigateMediaItems: automotivePlaybackAllowedNow, onPlay: () => unawaited(play()), onPause: () => unawaited(pause()), onTogglePlayPause: () => unawaited(togglePlayPause()), @@ -873,6 +904,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO @override Future play() async { + if (!automotivePlaybackAllowedNow()) { + appLogger.d('Music play denied while automotive playback is restricted'); + return; + } final player = _player; if (player == null || _currentTrack == null) return; final generation = _generation; @@ -887,9 +922,23 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO } _requestArmNext(); } + if (!automotivePlaybackAllowedNow()) { + appLogger.d('Music play denied while automotive playback is restricted'); + return; + } await player.play(); if (!_isCurrentTransport(player, generation)) return; + if (!automotivePlaybackAllowedNow()) { + await player.pause(); + if (_isCurrentTransport(player, generation)) { + _setStatus(MusicPlaybackStatus.paused); + } + return; + } _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 @@ -913,17 +962,36 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO return player.state.isActive ? pause() : play(); } - /// Apple TV only (observer registered in the constructor): pause when the - /// app leaves the foreground — tvOS background audio is not attempted in - /// v1, so playback must not continue under the home screen. + /// On Apple TV, pause when the app leaves the foreground because tvOS + /// background audio is not attempted in v1. On AAOS, stop audio whenever + /// the app is not resumed to comply with driver-distraction rule DD-2. @override void didChangeAppLifecycleState(AppLifecycleState state) { if (_disposed) return; - if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) { - if (isPlaying) { - appLogger.d('App backgrounded on Apple TV — pausing music playback'); - unawaited(pause()); + if (PlatformDetector.isAutomotive()) { + if (!automotivePlaybackAllowedNow()) { + _invalidateArmRequests(); + _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()); } } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 2ad9d0bc..e13b8116 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -169,14 +169,13 @@ class _AppLocalePref extends Pref { Future 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 { const _AutoPipPref() : super('auto_pip'); @override bool readFrom(BaseSharedPreferencesService svc) { - if (!Platform.isAndroid && !Platform.isIOS && !Platform.isMacOS) return false; - if (PlatformDetector.isTV()) return false; + if (!PlatformDetector.supportsPictureInPicture()) return false; return svc.prefs.getBool(key) ?? !Platform.isMacOS; } diff --git a/lib/utils/orientation_helper.dart b/lib/utils/orientation_helper.dart index bde88029..0444f0a8 100644 --- a/lib/utils/orientation_helper.dart +++ b/lib/utils/orientation_helper.dart @@ -11,6 +11,7 @@ class OrientationHelper { /// This should be called when leaving full-screen experiences like /// the video player to restore the app's default orientation behavior. static void restoreDefaultOrientations(BuildContext context) { + if (PlatformDetector.isAutomotive()) return; final isPhone = PlatformDetector.isPhone(context); if (isPhone) { @@ -30,6 +31,7 @@ class OrientationHelper { /// Used by the video player to force landscape orientation during playback. static void setLandscapeOrientation() { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + if (PlatformDetector.isAutomotive()) return; SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); } diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart index ca05ca19..6bf084d1 100644 --- a/lib/utils/platform_detector.dart +++ b/lib/utils/platform_detector.dart @@ -12,12 +12,20 @@ const _androidFeatureTelevision = 'android.hardware.type.television'; const _androidFeatureLeanback = 'android.software.leanback'; const _androidFeatureFireTv = 'amazon.hardware.fire_tv'; const _androidFeatureTouchscreen = 'android.hardware.touchscreen'; +const _androidFeatureAutomotive = 'android.hardware.type.automotive'; class AndroidTvFeatureDetection { 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 reasons; - const AndroidTvFeatureDetection({required this.isTv, required this.reasons}); + const AndroidTvFeatureDetection({required this.isTv, required this.isAutomotive, required this.reasons}); } AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable features) { @@ -28,7 +36,16 @@ AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable fea if (featureSet.contains(_androidFeatureFireTv)) reasons.add('fire_tv'); 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. @@ -37,10 +54,12 @@ class TvDetectionService { @visibleForTesting static set debugDetectionGate(Future? value) => _singleton.debugGate = value; static bool? _debugAppleTVOverride; + static bool? _debugAutomotiveOverride; bool _detected = false; bool _forceTv = false; bool _isTV = false; bool _isAppleTV = false; + bool _isAutomotive = false; bool _initialized = false; List _detectionReasons = const []; @@ -62,6 +81,7 @@ class TvDetectionService { final detection = nativeDetection ?? detectAndroidTvFromSystemFeatures((await deviceInfo.androidInfo).systemFeatures); _detected = detection.isTv; + _isAutomotive = detection.isAutomotive; _detectionReasons = detection.reasons; } else if (Platform.isIOS) { if (_tvosBuild) { @@ -91,6 +111,10 @@ class TvDetectionService { 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 get _effectiveDetectionReasons { final reasons = [..._detectionReasons]; if (_forceTv && !reasons.contains('force_tv')) reasons.add('force_tv'); @@ -104,8 +128,9 @@ class TvDetectionService { final reasonsValue = result['reasons']; final reasons = reasonsValue is Iterable ? reasonsValue.whereType().toList() : []; final isTv = result['isTv'] == true; + final isAutomotive = result['isAutomotive'] == true; if (isTv && reasons.isEmpty) reasons.add('native'); - return AndroidTvFeatureDetection(isTv: isTv, reasons: reasons); + return AndroidTvFeatureDetection(isTv: isTv && !isAutomotive, isAutomotive: isAutomotive, reasons: reasons); } on MissingPluginException { return null; } on PlatformException { @@ -139,15 +164,24 @@ class TvDetectionService { /// Synchronous Apple TV check (returns false if not initialized or not tvOS). 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 static void debugSetAppleTVOverride(bool? value) { _debugAppleTVOverride = value; } + @visibleForTesting + static void debugSetAutomotiveOverride(bool? value) { + _debugAutomotiveOverride = value; + } + @visibleForTesting static void debugReset() { _singleton.debugReset(); _debugAppleTVOverride = null; + _debugAutomotiveOverride = null; } static List tvDetectionReasonsSync() => _singleton.instance?._effectiveDetectionReasons ?? const []; @@ -165,6 +199,11 @@ class PlatformDetector { 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) static bool shouldUseSideNavigation(BuildContext context) { return isDesktop(context) || isTV(); @@ -229,7 +268,9 @@ class PlatformDetector { } 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 diff --git a/lib/widgets/video_controls/parts/visibility.dart b/lib/widgets/video_controls/parts/visibility.dart index d9fe39ac..e4f87b8c 100644 --- a/lib/widgets/video_controls/parts/visibility.dart +++ b/lib/widgets/video_controls/parts/visibility.dart @@ -100,6 +100,7 @@ extension _PlexVideoControlsVisibilityMethods on _PlexVideoControlsState { /// [SettingsService.rotationLocked] via [bindEffect] so any change — from /// this toggle or from the settings screen — fires the same SystemChrome call. void _applyRotationLock(bool locked) { + if (PlatformDetector.isAutomotive()) return; unawaited( SystemChrome.setPreferredOrientations( locked ? const [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight] : DeviceOrientation.values, diff --git a/test/screens/video_player/video_player_automotive_test.dart b/test/screens/video_player/video_player_automotive_test.dart new file mode 100644 index 00000000..d9853d6a --- /dev/null +++ b/test/screens/video_player/video_player_automotive_test.dart @@ -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 = []; + // 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']); + }); +} diff --git a/test/services/driver_distraction_test.dart b/test/services/driver_distraction_test.dart new file mode 100644 index 00000000..1bb3572b --- /dev/null +++ b/test/services/driver_distraction_test.dart @@ -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); + }); + }); +} diff --git a/test/services/music/music_playback_automotive_test.dart b/test/services/music/music_playback_automotive_test.dart new file mode 100644 index 00000000..9f3c8f0b --- /dev/null +++ b/test/services/music/music_playback_automotive_test.dart @@ -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 backgroundModeCalls = []; + + @override + Future 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 players; + final MultiServerManager serverManager; + + music_fakes.FakePlayer get player => players.single; + + factory _Harness.create() { + final controls = _RecordingMediaControlsManager(); + final players = []; + 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 start(List 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]); + }); +} diff --git a/test/services/settings_service_test.dart b/test/services/settings_service_test.dart index 891ff708..1c4c4282 100644 --- a/test/services/settings_service_test.dart +++ b/test/services/settings_service_test.dart @@ -20,6 +20,7 @@ void main() { tearDown(() { TvDetectionService.debugSetAppleTVOverride(null); + TvDetectionService.debugSetAutomotiveOverride(null); }); group('SettingsService.parseMpvConfigText', () { @@ -230,6 +231,17 @@ void main() { 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', () { diff --git a/test/utils/platform_detector_test.dart b/test/utils/platform_detector_test.dart index 6cff0650..7624c2c5 100644 --- a/test/utils/platform_detector_test.dart +++ b/test/utils/platform_detector_test.dart @@ -77,5 +77,40 @@ void main() { expect(detection.isTv, isFalse); 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); + }); }); }