Files
SpotiFLAC-Mobile/ios/Runner/AudioOutputView.swift
T

195 lines
7.5 KiB
Swift

import AVKit
import Flutter
import MediaPlayer
import UIKit
/// iOS owns the lock-screen layout, transport glyphs and AirPlay button.
/// Supply its favorite command with the same state/action as the Mornye player.
final class PlaybackNotificationBridge {
private let channel: FlutterMethodChannel
private var target: Any?
init(messenger: FlutterBinaryMessenger) {
channel = FlutterMethodChannel(name: "com.zarz.spotiflac/playback_notification", binaryMessenger: messenger)
let command = MPRemoteCommandCenter.shared().likeCommand
target = command.addTarget { [weak self] _ in
guard let self else { return .commandFailed }
self.channel.invokeMethod("favorite", arguments: nil)
return .success
}
command.isEnabled = false
channel.setMethodCallHandler { call, result in
guard call.method == "update", let values = call.arguments as? [String: Any] else {
result(FlutterMethodNotImplemented)
return
}
command.isEnabled = values["enabled"] as? Bool ?? false
command.isActive = values["loved"] as? Bool ?? false
command.localizedTitle = values["label"] as? String ?? "Favorite"
result(nil)
}
}
deinit {
if let target { MPRemoteCommandCenter.shared().likeCommand.removeTarget(target) }
channel.setMethodCallHandler(nil)
}
}
/// Read-only route and stream facts for the player's audio information sheet.
final class AudioRouteInfoBridge {
private let channel: FlutterMethodChannel
init(messenger: FlutterBinaryMessenger) {
channel = FlutterMethodChannel(name: "com.zarz.spotiflac/audio_route", binaryMessenger: messenger)
channel.setMethodCallHandler { call, result in
guard call.method == "read" else {
result(FlutterMethodNotImplemented)
return
}
let path = (call.arguments as? [String: Any])?["path"] as? String
Task {
var info = Self.route()
if let path { info["stream"] = await Self.stream(path) }
let payload = info
await MainActor.run { result(payload) }
}
}
}
private static func route() -> [String: Any] {
let session = AVAudioSession.sharedInstance()
var info: [String: Any] = [
"platform": "ios",
"mixerSampleRate": Int(session.sampleRate.rounded()),
"ioBufferMs": session.ioBufferDuration * 1000,
"outputLatencyMs": session.outputLatency * 1000,
"outputChannels": session.outputNumberOfChannels,
"maxOutputChannels": session.maximumOutputNumberOfChannels,
"volume": Double(session.outputVolume),
]
if let output = session.currentRoute.outputs.first {
info["device"] = [
"type": portType(output.portType),
"name": output.portName,
"channelCounts": [output.channels?.count ?? 0].filter { $0 > 0 },
"spatial": output.isSpatialAudioEnabled,
] as [String: Any]
}
return info
}
private static func portType(_ type: AVAudioSession.Port) -> String {
switch type {
case .builtInSpeaker: return "speaker"
case .builtInReceiver: return "earpiece"
case .headphones: return "wired_headphones"
case .bluetoothA2DP, .bluetoothHFP: return "bluetooth"
case .bluetoothLE: return "bluetooth_le"
case .airPlay: return "airplay"
case .usbAudio: return "usb"
case .HDMI: return "hdmi"
case .lineOut: return "line"
case .carAudio: return "car"
default: return "other"
}
}
private static func stream(_ path: String) async -> [String: Any]? {
let url = path.hasPrefix("/") ? URL(fileURLWithPath: path) : URL(string: path)
guard let url, url.isFileURL else { return nil }
let asset = AVURLAsset(url: url)
guard let track = try? await asset.loadTracks(withMediaType: .audio).first,
let formats = try? await track.load(.formatDescriptions),
let description = formats.first,
let basic = CMAudioFormatDescriptionGetStreamBasicDescription(description)?.pointee
else { return nil }
let rate = (try? await track.load(.estimatedDataRate)) ?? 0
let id = basic.mFormatID
let fourCC = String(
bytes: [24, 16, 8, 0].map { UInt8((id >> $0) & 0xff) },
encoding: .ascii
)?.trimmingCharacters(in: .whitespaces) ?? ""
return [
"mime": fourCC,
"sampleRate": Int(basic.mSampleRate.rounded()),
"channels": Int(basic.mChannelsPerFrame),
"pcmBits": Int(basic.mBitsPerChannel),
"bitrate": Int(rate.rounded()),
].filter { ($0.value as? Int).map { $0 > 0 } ?? !(($0.value as? String)?.isEmpty ?? true) }
}
deinit {
channel.setMethodCallHandler(nil)
}
}
final class AudioOutputViewFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView {
AudioOutputView(frame: frame, id: viewId, messenger: messenger, arguments: args)
}
}
private final class AudioOutputView: NSObject, FlutterPlatformView, AVRoutePickerViewDelegate {
private let picker: AVRoutePickerView
private let channel: FlutterMethodChannel
init(frame: CGRect, id: Int64, messenger: FlutterBinaryMessenger, arguments: Any?) {
picker = AVRoutePickerView(frame: frame)
channel = FlutterMethodChannel(name: "com.zarz.spotiflac/audio_output/\(id)", binaryMessenger: messenger)
super.init()
picker.backgroundColor = .clear
picker.prioritizesVideoDevices = false
picker.delegate = self
update(arguments)
channel.setMethodCallHandler { [weak self] call, result in
if call.method == "update" {
self?.update(call.arguments)
result(nil)
} else {
result(FlutterMethodNotImplemented)
}
}
}
private func update(_ arguments: Any?) {
guard let values = arguments as? [String: Any] else { return }
if let value = values["color"] as? NSNumber {
let argb = value.uint32Value
picker.tintColor = UIColor(
red: CGFloat((argb >> 16) & 0xff) / 255,
green: CGFloat((argb >> 8) & 0xff) / 255,
blue: CGFloat(argb & 0xff) / 255,
alpha: CGFloat((argb >> 24) & 0xff) / 255
)
picker.activeTintColor = picker.tintColor
}
picker.accessibilityLabel = values["label"] as? String
}
func view() -> UIView { picker }
func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) {
channel.invokeMethod("pickerChanged", arguments: true)
}
func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) {
channel.invokeMethod("pickerChanged", arguments: false)
}
deinit {
channel.setMethodCallHandler(nil)
}
}