feat: full Apple CarPlay integration#1194
Conversation
Greptile SummaryElo żelo solvrowiczu! This PR completes the Apple CarPlay integration by introducing a
Confidence Score: 5/5Safe to merge; all previously raised blocking concerns are addressed and no new defects are introduced. The CarPlay setup logic is correct: the _initialized flag is set synchronously before the first await to prevent double-init races, it is reset on disconnect and on exception, and complete() is always called via try/finally. The lifecycle observer removal is intentional and consistent with the CarPlay background-playback goal. No data loss, crash path, or broken contract was found in the changed files. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant CP as CarPlay (iOS)
participant CS as CarPlayService
participant RAH as RadioAudioHandlerBridge
participant FCP as FlutterCarplay (plugin)
Note over CS: initialize() called at app start
CS->>FCP: addListenerOnConnectionChange
CP-->>CS: connected event
CS->>CS: _onCarplayConnectionChange(connected)
CS->>CS: "_setupCarplayTemplate() [_initialized=true]"
CS->>FCP: setRootTemplate(CPListTemplate)
CS->>FCP: forceUpdateRootTemplate()
FCP-->>CP: Show Radio LUZ list item
Note over CP: User taps Radio LUZ
CP-->>CS: onPress(complete, self)
CS->>RAH: play()
RAH-->>CS: audio playing
CS->>FCP: showSharedNowPlaying()
CS->>CP: complete()
Note over CP: User unplugs / reconnects
CP-->>CS: disconnected event
CS->>CS: "_initialized = false"
CP-->>CS: connected event
CS->>CS: _setupCarplayTemplate() [re-initializes]
Reviews (13): Last reviewed commit: "fix: eh" | Re-trigger Greptile |
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
|
integration tests are not passing |
4f74dfe to
f19c2c4
Compare
|
|
||
| //register lifecycle observer to stop playback when app is killed | ||
| WidgetsBinding.instance.addObserver(this); | ||
| } | ||
|
|
||
| @override | ||
| void didChangeAppLifecycleState(AppLifecycleState state) { | ||
| // Stop playback when app is detached (killed) | ||
| if (state == AppLifecycleState.detached) { | ||
| unawaited(stop()); | ||
| } | ||
| } |
There was a problem hiding this comment.
But wait, Greptile can be wrong on this...
@greptile what about Android? Is it affected by this change?
There was a problem hiding this comment.
Yes, Android is affected too — but it's intentional and actually correct behavior.
On Android, audio_service runs audio as a foreground service, which is designed to survive app death. The foreground service exposes a notification with media controls, and stopping playback is expected to happen through those controls (or by the user dismissing the notification), not via an app lifecycle callback.
The old didChangeAppLifecycleState + detached approach was actually fighting against this: it would abruptly stop the foreground service when the Flutter engine detached, which is wrong for a background audio player — and exactly what broke CarPlay (the screenshot you shared likely shows CarPlay still showing the player after the phone app is killed).
So removing it is correct for both platforms:
- iOS/CarPlay: audio correctly continues when phone app is killed while CarPlay is active
- Android/Android Auto: audio correctly continues as a foreground service; stopping is handled via the notification dismiss/stop button
The audio_service package owns the Android service lifecycle — the Flutter lifecycle observer was the wrong place to handle this.
There was a problem hiding this comment.
@greptile the app should not survive app death (it should stop playing when closed from recents, on iOS and Android), but it should not stop playing when connected to CarPlay or Android Auto. What's the solution then?
There was a problem hiding this comment.
The solution is to restore the lifecycle observer but check CarPlay/Android Auto connectivity before stopping.
iOS/CarPlay — you already have FlutterCarplay.connectionStatus available, so you can gate it:
// Restore WidgetsBindingObserver mixin + addObserver/removeObserver calls, then:
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.detached) {
final isCarPlayActive = !kIsWeb &&
Platform.isIOS &&
FlutterCarplay.connectionStatus == ConnectionStatusTypes.connected.name;
if (!isCarPlayActive) {
unawaited(stop());
}
}
}Android/Android Auto — trickier. audio_service's foreground service doesn't expose onTaskRemoved to Flutter. However, when Android Auto is connected it binds to the MediaBrowserService, and Android natively protects bound services from task-removal teardown — so Android Auto survival is already handled by the OS. For "stop when killed from recents without Android Auto", you'd need a native Android override of onTaskRemoved in the service class, which audio_service 0.18.x doesn't expose from the Flutter side.
Practical split:
- iOS: restore the observer with the CarPlay guard above — covers both cases correctly
- Android: follows standard media app convention (audio continues via notification after task removal); Android Auto protection is implicit via service binding

Previously, the Apple CarPlay integration was not actually finished. On iOS, a complete UI has to be defined, unlike on Android Auto.
This PR introduces a full CarPlay interface and lets users start radio playback even when the phone app isn’t open.
It also resolves the duplicated album and artist text shown in the player controls.