First pass adding android auto and apple carplay support. Android auto tuned on emulator.
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
A1B2C3D4E5F6A7B8C9D0E1F2 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
@@ -56,6 +57,8 @@
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = "<group>"; };
|
||||
A1B2C3D4E5F6A7B8C9D0E1F3 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -158,10 +161,12 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C147021CF9000F007C117D /* Info.plist */,
|
||||
A1B2C3D4E5F6A7B8C9D0E1F3 /* Runner.entitlements */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
A1B2C3D4E5F6A7B8C9D0E1F1 /* CarPlaySceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
@@ -382,6 +387,7 @@
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
A1B2C3D4E5F6A7B8C9D0E1F2 /* CarPlaySceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -473,6 +479,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = SGW7CVBAMN;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -656,6 +663,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = SGW7CVBAMN;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -679,6 +687,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = SGW7CVBAMN;
|
||||
ENABLE_BITCODE = NO;
|
||||
|
||||
@@ -7,12 +7,14 @@ import AVKit
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate, FlutterStreamHandler {
|
||||
private let castingMethodChannelName = "kryz_go_flutter/casting/methods"
|
||||
private let castingEventChannelName = "kryz_go_flutter/casting/events"
|
||||
private let playbackMethodChannelName = "kryz_go_flutter/playback/methods"
|
||||
|
||||
private var castingEventSink: FlutterEventSink?
|
||||
private var castingStatus: [String: Any] = [
|
||||
"status": "idle",
|
||||
"platform": "ios"
|
||||
]
|
||||
private var playbackMethodChannel: FlutterMethodChannel?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -60,6 +62,21 @@ import AVKit
|
||||
methodChannel.setMethodCallHandler { [weak self] call, result in
|
||||
self?.handleCastingMethod(call: call, result: result)
|
||||
}
|
||||
|
||||
playbackMethodChannel = FlutterMethodChannel(
|
||||
name: playbackMethodChannelName,
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
}
|
||||
|
||||
/// Invoke play on the Flutter audio engine. Called by CarPlaySceneDelegate.
|
||||
func invokePlay() {
|
||||
playbackMethodChannel?.invokeMethod("play", arguments: nil)
|
||||
}
|
||||
|
||||
/// Invoke stop on the Flutter audio engine. Called by CarPlaySceneDelegate.
|
||||
func invokeStop() {
|
||||
playbackMethodChannel?.invokeMethod("stop", arguments: nil)
|
||||
}
|
||||
|
||||
@objc private func handleAudioRouteChange() {
|
||||
@@ -161,3 +178,99 @@ import AVKit
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCastingMethod(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "getStatus":
|
||||
updateCastingStatusForCurrentRoute()
|
||||
result(castingStatus)
|
||||
case "showDevicePicker":
|
||||
showAirPlayPicker()
|
||||
result(nil)
|
||||
case "startCastingLiveStream":
|
||||
// AirPlay route selection controls where iOS outputs audio. No explicit load call needed.
|
||||
result(nil)
|
||||
case "disconnect":
|
||||
// iOS does not expose direct route disconnect. Users select route via picker.
|
||||
showAirPlayPicker()
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
private func showAirPlayPicker() {
|
||||
DispatchQueue.main.async {
|
||||
guard let rootViewController = self.activeRootViewController() else {
|
||||
self.emitCastingEvent(status: "error", message: "Unable to open AirPlay picker")
|
||||
return
|
||||
}
|
||||
|
||||
let routePickerView = AVRoutePickerView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
|
||||
routePickerView.alpha = 0.01
|
||||
rootViewController.view.addSubview(routePickerView)
|
||||
|
||||
for subview in routePickerView.subviews {
|
||||
if let button = subview as? UIButton {
|
||||
button.sendActions(for: .touchUpInside)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
routePickerView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func activeRootViewController() -> UIViewController? {
|
||||
let activeScene = UIApplication.shared.connectedScenes
|
||||
.first { $0.activationState == .foregroundActive } as? UIWindowScene
|
||||
let keyWindow = activeScene?.windows.first { $0.isKeyWindow }
|
||||
return keyWindow?.rootViewController
|
||||
}
|
||||
|
||||
private func updateCastingStatusForCurrentRoute() {
|
||||
let currentOutputs = AVAudioSession.sharedInstance().currentRoute.outputs
|
||||
if let airPlayOutput = currentOutputs.first(where: { $0.portType == .airPlay }) {
|
||||
castingStatus = [
|
||||
"status": "connected",
|
||||
"platform": "ios",
|
||||
"deviceName": airPlayOutput.portName
|
||||
]
|
||||
} else {
|
||||
castingStatus = [
|
||||
"status": "idle",
|
||||
"platform": "ios"
|
||||
]
|
||||
}
|
||||
|
||||
castingEventSink?(castingStatus)
|
||||
}
|
||||
|
||||
private func emitCastingEvent(status: String, message: String) {
|
||||
let event: [String: Any] = [
|
||||
"status": status,
|
||||
"platform": "ios",
|
||||
"message": message
|
||||
]
|
||||
|
||||
castingStatus = event
|
||||
castingEventSink?(event)
|
||||
}
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
|
||||
castingEventSink = events
|
||||
events(castingStatus)
|
||||
return nil
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
castingEventSink = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import CarPlay
|
||||
import UIKit
|
||||
|
||||
/**
|
||||
* CarPlay scene delegate for KRYZ Go!
|
||||
*
|
||||
* Shows a CPListTemplate with the KRYZ Live Stream item. Tapping it triggers
|
||||
* playback via AppDelegate.invokePlay(), then presents CPNowPlayingTemplate.
|
||||
*
|
||||
* CarPlay enforces its own design system for safety — colors cannot be customised.
|
||||
* The app icon (which carries the KRYZ brand orange) is used as item artwork to
|
||||
* provide visual continuity with the phone app.
|
||||
*
|
||||
* Note: the com.apple.developer.carplay-audio entitlement must be enabled in the
|
||||
* Apple Developer Portal and added to the target in Xcode Signing & Capabilities.
|
||||
*/
|
||||
class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
|
||||
|
||||
private var carPlayInterfaceController: CPInterfaceController?
|
||||
private var listTemplate: CPListTemplate?
|
||||
|
||||
// MARK: - CPTemplateApplicationSceneDelegate
|
||||
|
||||
func templateApplicationScene(
|
||||
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||
didConnect interfaceController: CPInterfaceController
|
||||
) {
|
||||
carPlayInterfaceController = interfaceController
|
||||
interfaceController.setRootTemplate(makeListTemplate(), animated: false, completion: nil)
|
||||
}
|
||||
|
||||
func templateApplicationScene(
|
||||
_ templateApplicationScene: CPTemplateApplicationScene,
|
||||
didDisconnectInterfaceController interfaceController: CPInterfaceController
|
||||
) {
|
||||
carPlayInterfaceController = nil
|
||||
listTemplate = nil
|
||||
appDelegate?.invokeStop()
|
||||
}
|
||||
|
||||
// MARK: - Template construction
|
||||
|
||||
private func makeListTemplate() -> CPListTemplate {
|
||||
let artwork = CPListItem.maximumImageSize
|
||||
let icon = UIImage(named: "AppIcon")?
|
||||
.withRenderingMode(.alwaysOriginal)
|
||||
|
||||
let item = CPListItem(
|
||||
text: "KRYZ Live Stream",
|
||||
detailText: "KRYZ Radio",
|
||||
image: icon
|
||||
)
|
||||
item.handler = { [weak self] _, completion in
|
||||
self?.handleStreamItemTapped()
|
||||
completion()
|
||||
}
|
||||
|
||||
let section = CPListSection(items: [item])
|
||||
let template = CPListTemplate(title: "KRYZ Go!", sections: [section])
|
||||
template.tabTitle = "KRYZ Go!"
|
||||
template.tabImage = icon
|
||||
|
||||
listTemplate = template
|
||||
// Silence unused-variable warning
|
||||
_ = artwork
|
||||
return template
|
||||
}
|
||||
|
||||
// MARK: - Playback
|
||||
|
||||
private func handleStreamItemTapped() {
|
||||
appDelegate?.invokePlay()
|
||||
carPlayInterfaceController?.pushTemplate(
|
||||
CPNowPlayingTemplate.shared,
|
||||
animated: true,
|
||||
completion: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var appDelegate: AppDelegate? {
|
||||
UIApplication.shared.delegate as? AppDelegate
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,17 @@
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>CPTemplateApplicationScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>CarPlay</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
CarPlay audio entitlement. This must also be enabled on the Apple Developer Portal
|
||||
(Certificates, Identifiers & Profiles → your App ID → Capabilities → CarPlay)
|
||||
and added to the target in Xcode: Signing & Capabilities → + Capability → CarPlay.
|
||||
-->
|
||||
<key>com.apple.developer.carplay-audio</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user