Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Sources/App/Assist/AssistViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ final class AssistViewModel: NSObject, ObservableObject {

@Published var inputText = ""
@Published var isRecording = false
@Published var isThinking = false
@Published var isSpeaking = false
@Published var showError = false
@Published var focusOnInput = false
@Published var errorMessage = ""
Expand Down
40 changes: 40 additions & 0 deletions Sources/App/Scenes/CarPlaySceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class CarPlaySceneDelegate: UIResponder {
private var interfaceController: CPInterfaceController?
private var entitiesSubscriptionToken: HACancellable?
private var quickAccessEntitiesSubscriptionTokens: [HACancellable?] = []
private var cancellables = Set<AnyCancellable>()

private var domainsListTemplate: (any CarPlayTemplateProvider)?
private var serversListTemplate: (any CarPlayTemplateProvider)?
Expand All @@ -51,6 +52,7 @@ class CarPlaySceneDelegate: UIResponder {
private var latestStates: HACachedStates?
private var latestStatesServerId: String?
private var latestQuickAccessStatesPerServer: [String: HACachedStates] = [:]
public var carPlaySceneDelegate: CarPlaySceneDelegate?

private var preferredServerId: String {
prefs.string(forKey: CarPlayServersListTemplate.carPlayPreferredServerKey) ?? ""
Expand All @@ -66,6 +68,32 @@ class CarPlaySceneDelegate: UIResponder {
subscribeToEntitiesChanges()
}

@available(iOS 16.0, *)
public func presentAssistTemplate(server: Server, pipeline: String, withVoice: Bool) {
guard let interfaceController = self.interfaceController else { return }

let assistTemplate = CarPlayAssistTemplate()
assistTemplate.interfaceController = interfaceController

let viewModel = AssistViewModel(
server: server,
preferredPipelineId: pipeline,
audioRecorder: AudioRecorder(),
audioPlayer: AudioPlayer(),
assistService: AssistService(server: server),
autoStartRecording: withVoice
)

viewModel.objectWillChange.sink { [weak assistTemplate, weak interfaceController] _ in
guard let assistTemplate = assistTemplate, let interfaceController = interfaceController else { return }
assistTemplate.state = assistTemplate.mapViewModelToState(viewModel)
interfaceController.setRootTemplate(assistTemplate.template, animated: true, completion: nil)
}.store(in: &cancellables)

interfaceController.setRootTemplate(assistTemplate.template, animated: true, completion: nil)
print("Presenting Assist template for server: \(server.identifier.rawValue), pipeline: \(pipeline), withVoice: \(withVoice)")
}

private func setTemplates(config: CarPlayConfig?) {
var visibleTemplates: [any CarPlayTemplateProvider] = []
if let config {
Expand Down Expand Up @@ -253,6 +281,18 @@ extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate {
) {
self.interfaceController = interfaceController
self.interfaceController?.delegate = self

if let activity = templateApplicationScene.userActivity,
let userInfo = activity.userInfo {
if activity.activityType == SceneActivity.assist.activityIdentifier {
if let serverId = userInfo["serverId"] as? String,
let pipelineId = userInfo["pipelineId"] as? String,
let withVoice = userInfo["withVoice"] as? Bool,
let server = Current.servers.all.first(where: { $0.identifier.rawValue == serverId }) {
presentAssistTemplate(server: server, pipeline: pipelineId, withVoice: withVoice)
}
}
}
}

func sceneWillEnterForeground(_ scene: UIScene) {
Expand Down
65 changes: 65 additions & 0 deletions Sources/CarPlay/Templates/CarPlayAssistTemplate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import CarPlay
import Foundation
import HAKit
import Shared
import SwiftUI

@available(iOS 16.0, *)
enum CarPlayAssistState {
case idle
case listening
case thinking
case speaking
}

@available(iOS 16.0, *)
final class CarPlayAssistTemplate: CarPlayTemplateProvider {
var state: CarPlayAssistState = .idle

var template: CPInterfaceTemplate {
let statusText: String
let detailText: String?
let image: CPImage?

switch state {
case .idle:
statusText = "Assist"
detailText = "Tap to start"
image = CPImage(systemSymbol: .mic)
case .listening:
statusText = "Listening"
detailText = "Speak your command"
image = CPImage(systemSymbol: .waveform)
case .thinking:
statusText = "Thinking"
detailText = "Processing..."
image = CPImage(systemSymbol: .ellipsis)
case .speaking:
statusText = "Speaking"
detailText = "Playing response"
image = CPImage(systemSymbol: .speaker)
}

let item = CPListItem(text: statusText, detail: detailText, image: image)
let section = CPListSection(items: [item])
return CPInterfaceTemplate(templateFields: [section])
}

var interfaceController: CPInterfaceController?

func entitiesStateChange(serverId: String, entities: HACachedStates) {}
func update() {}

@available(iOS 16.0, *)
func mapViewModelToState(_ viewModel: AssistViewModel) -> CarPlayAssistState {
if viewModel.isSpeaking {
return .speaking
} else if viewModel.isThinking {
return .thinking
} else if viewModel.isRecording {
return .listening
} else {
return .idle
}
}
}
36 changes: 24 additions & 12 deletions Sources/Extensions/Widgets/Assist/AssistAppIntent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,30 @@ struct AssistAppIntent: AppIntent {

func perform() async throws -> some IntentResult {
#if !WIDGET_EXTENSION
DispatchQueue.main.async {
guard let server = Current.servers.all
.first(where: { $0.identifier.rawValue == pipeline.serverId }) ?? Current
.servers.all.first else { return }
Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise)
.done { webViewController in
webViewController.webViewExternalMessageHandler.showAssist(
server: server,
pipeline: pipeline.id,
autoStartRecording: withVoice
)
}
if Current.sceneManager.existingScenes(for: .assist).isEmpty {
// Mobile context: This is what existing code was doing
DispatchQueue.main.async {
guard let server = Current.servers.all
.first(where: { $0.identifier.rawValue == pipeline.serverId }) ?? Current
.servers.all.first else { return }
Current.sceneManager.webViewWindowControllerPromise.then(\.webViewControllerPromise)
.done { webViewController in
webViewController.webViewExternalMessageHandler.showAssist(
server: server,
pipeline: pipeline.id,
autoStartRecording: withVoice
)
}
}
} else {
// CarPlay context: Signal the CarPlay scene with the payload
let userInfo: [AnyHashable: Any] = [
"pipelineId": pipeline.id,
"serverId": pipeline.serverId,
"withVoice": withVoice
]

Current.sceneManager.activateAnyScene(for: .assist, with: userInfo)
}
#endif
return .result()
Expand Down