I am implementing Siri/Shortcuts for radio app for iOS. I have implemented AppIntent that sends notification to app and app should start playing the stream in AVPlayer.
AppIntent sometimes works, sometimes it doesn't. So far I couldn't find the pattern when/why it works and when/why it doesn't. Sometimes it works even if app is killed or is in the background. Sometimes it doesn't work when the app is in the background and when it is killed.
I have been observing logs in Console and apparently sometimes it stops when AVPlayer tries to figure out buffer size (then I am getting in console AVPlayerWaitingToMinimizeStallsReason and the AVPlayerItem status is set to .unknown). Sometimes it figures out quickly (for the same stream) and starts playing.
Sometimes when the app is killed, after AppIntent call the app is launched in the background (at least I see it as a process in Console) and receives notification from AppIntent and start playing. Sometimes... the app is not called at all, and its process is not visible in the console, so it doesn't receives the notification and doesn't play.
I have setup Session correctly (set to .playback without any options and activated), I set AVPlayerItem's preferredForwardBufferDuration to 0 (default), and AVPlayer's automaticallyWaitsToMinimizeStalling to true.
Background processing, Audio, AirPlay, Picture in Picture and Siri are added in Singing & Capabilities section of the app project settings.
Here are the code examples:
Play AppIntent (Stop App Intent is constructed the same way):
@available(iOS 16, *)
struct PlayStationIntent: AudioPlaybackIntent {
static let title: LocalizedStringResource = "Start playing"
static let description = IntentDescription("Plays currently selected radio")
@MainActor
func perform() async throws -> some IntentResult {
NotificationCenter.default.post(name: IntentsNotifications.siriPlayCurrentStationNotificationName, object: nil)
return .result()
}
}
AppShortcutsProvider:
struct RadioTestShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: PlayStationIntent(),
phrases: [
"Start station in \(.applicationName)",
],
shortTitle: LocalizedStringResource("Play station"),
systemImageName: "radio"
)
}
}
Player object:
class Player: ObservableObject {
private let session = AVAudioSession.sharedInstance()
private let streamURL = URL(string: "http://radio.rockserwis.fm/live")!
private var player: AVPlayer?
private var item: AVPlayerItem?
var cancellables = Set<AnyCancellable>()
typealias UInfo = [AnyHashable: Any]
@Published var status: Player.Status = .stopped
@Published var isPlaying = false
func setupSession() {
do {
try session.setCategory(.playback)
} catch {
print("*** Error setting up category audio session: \(error), \(error.localizedDescription)")
}
do {
try session.setActive(true)
} catch {
print("*** Error setting audio session active: \(error), \(error.localizedDescription)")
}
}
func setupPlayer() {
item = AVPlayerItem(url: streamURL)
item?.preferredForwardBufferDuration = TimeInterval(0)
player = AVPlayer(playerItem: item)
player?.automaticallyWaitsToMinimizeStalling = true
player?.allowsExternalPlayback = false
let metaDataOuptut = AVPlayerItemMetadataOutput(identifiers: nil)
}
func play() {
setupPlayer()
setupSession()
handleInterruption()
player?.play()
isPlaying = true
player?.currentItem?.publisher(for: \.status)
.receive(on: DispatchQueue.main)
.sink(receiveValue: { status in
self.handle(status: status)
})
.store(in: &self.cancellables)
}
func stop() {
player?.pause()
player = nil
isPlaying = false
status = .stopped
}
func handle(status: AVPlayerItem.Status) {
...
}
func handleInterruption() {
...
}
func handle(interruptionType: AVAudioSession.InterruptionType?, userInfo: UInfo?) {
...
}
}
extension Player {
enum Status {
case waiting, ready, failed, stopped
}
}
extension Player {
func setupRemoteTransportControls() {
...
}
}
Content view:
struct ContentView: View {
@EnvironmentObject var player: Player
var body: some View {
VStack(spacing: 20) {
Text("AppIntents Radio Test App")
.font(.title)
Button {
if player.isPlaying {
player.stop()
} else {
player.play()
}
} label: {
Image(systemName: player.isPlaying ? "pause.circle" : "play.circle")
.font(.system(size: 80))
}
}
.padding()
}
}
#Preview {
ContentView()
}
Main struct:
```import SwiftUI
@main
struct RadioTestApp: App {
let player = Player()
let siriPlayCurrentPub = NotificationCenter.default.publisher(for: IntentsNotifications.siriPlayCurrentStationNotificationName)
let siriStop = NotificationCenter.default.publisher(for: IntentsNotifications.siriStopRadioNotificationName)
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(player)
.onReceive(siriPlayCurrentPub, perform: { _ in
player.play()
})
.onReceive(siriStop, perform: { _ in
player.stop()
})
}
}
}