Skip to content

feat: full Apple CarPlay integration#1194

Open
MarmoPL wants to merge 15 commits into
mainfrom
carplay-fix
Open

feat: full Apple CarPlay integration#1194
MarmoPL wants to merge 15 commits into
mainfrom
carplay-fix

Conversation

@MarmoPL

@MarmoPL MarmoPL commented Jun 7, 2026

Copy link
Copy Markdown
Member

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.

@MarmoPL MarmoPL requested a review from simon-the-shark June 7, 2026 14:54
@MarmoPL MarmoPL self-assigned this Jun 7, 2026
@greptile-apps

greptile-apps Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Elo żelo solvrowiczu! This PR completes the Apple CarPlay integration by introducing a CarPlayService that builds and manages a full CarPlay UI template, allowing Radio LUZ playback to be started from CarPlay even when the phone app is not open. It also removes the lifecycle observer that would stop background playback, and fixes a duplicate album/artist display bug in the audio player notification.

  • CarPlayService wires FlutterCarplay to RadioAudioHandlerBridge, handles reconnect scenarios, and guards against async double-init with an _initialized flag set before the first await.
  • radio_audio_handler.dart drops WidgetsBindingObserver (and its detachedstop() call) so audio survives when the phone screen is off during CarPlay playback, and stops emitting the current song title as both album and artist to the notification.
  • Smoke tests gain a startApp helper that passes skipCarPlay: true so CarPlay native code is never touched in the test harness.

Confidence Score: 5/5

Safe 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

Filename Overview
lib/features/radio_luz/service/carplay_service.dart New service wrapping FlutterCarplay; previous review concerns (incomplete handler, _initialized race, reconnect blank screen) all addressed. Silent exception catch drops all diagnostic context.
lib/main.dart CarPlayService promoted to top-level late variable for GC retention; conditionally assigned under skipCarPlay=true in debug path, leaving the late variable unset in tests — safe today but a fragile contract.
lib/features/radio_luz/service/radio_audio_handler.dart Removes WidgetsBindingObserver so background CarPlay playback survives; fixes duplicate now-playing text by emitting artist only instead of both album and artist.
integration_test/smoke_test.dart Extracts startApp helper with consistent viewport sizing and skipCarPlay=true so the native CarPlay plugin is never touched in the test harness.
ios/Runner/Info.plist Adds required CPTemplateApplicationSceneSessionRoleApplication entry pointing to flutter_carplay's scene delegate.
pubspec.yaml Adds flutter_carplay ^1.3.3 dependency.

Sequence Diagram

sequenceDiagram
    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]
Loading

Reviews (13): Last reviewed commit: "fix: eh" | Re-trigger Greptile

Comment thread lib/main.dart Outdated
Comment thread lib/features/radio_luz/service/carplay_service.dart Outdated
@greptile-apps

greptile-apps Bot commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

Want your agent to iterate on Greptile's feedback? Try greploops.

@pull-request-size pull-request-size Bot added size/L and removed size/M labels Jun 7, 2026
Comment thread lib/features/radio_luz/service/carplay_service.dart Outdated
MarmoPL and others added 2 commits June 7, 2026 17:14
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
@simon-the-shark

Copy link
Copy Markdown
Member

integration tests are not passing

@simon-the-shark simon-the-shark force-pushed the main branch 3 times, most recently from 4f74dfe to f19c2c4 Compare June 7, 2026 21:10
Comment thread lib/features/radio_luz/service/carplay_service.dart
Comment thread lib/features/radio_luz/service/carplay_service.dart
Comment thread lib/features/radio_luz/service/carplay_service.dart
Comment on lines -70 to 81

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we drop this?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But wait, Greptile can be wrong on this...
@greptile what about Android? Is it affected by this change?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants