mirror of
https://gitlab.com/futo-org/fcast.git
synced 2025-08-23 07:42:49 +00:00
606 lines
24 KiB
Swift
606 lines
24 KiB
Swift
![]() |
import Network
|
||
|
import PhotosUI
|
||
|
import SwiftUI
|
||
|
import System
|
||
|
import CodeScanner
|
||
|
|
||
|
final class DevEventHandler: DeviceEventHandler {
|
||
|
let onStateChanged: @Sendable (DeviceConnectionState) -> Void
|
||
|
let dataModel: DataModel
|
||
|
|
||
|
init(
|
||
|
onStateChanged: @Sendable @escaping (DeviceConnectionState) -> Void,
|
||
|
dataModel: DataModel
|
||
|
) {
|
||
|
self.onStateChanged = onStateChanged
|
||
|
self.dataModel = dataModel
|
||
|
}
|
||
|
|
||
|
func connectionStateChanged(state: DeviceConnectionState) {
|
||
|
onStateChanged(state)
|
||
|
}
|
||
|
|
||
|
func volumeChanged(volume: Double) {
|
||
|
DispatchQueue.main.async {
|
||
|
self.dataModel.volume = volume
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func timeChanged(time: Double) {
|
||
|
DispatchQueue.main.async {
|
||
|
self.dataModel.time = time
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func playbackStateChanged(state: PlaybackState) {}
|
||
|
|
||
|
func durationChanged(duration: Double) {
|
||
|
DispatchQueue.main.async {
|
||
|
self.dataModel.duration = duration
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func speedChanged(speed: Double) {
|
||
|
DispatchQueue.main.async {
|
||
|
self.dataModel.speed = speed
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func sourceChanged(source: Source) {}
|
||
|
|
||
|
func keyEvent(event: GenericKeyEvent) {}
|
||
|
|
||
|
func mediaEvent(event: GenericMediaEvent) {}
|
||
|
|
||
|
func playbackError(message: String) {
|
||
|
print("Playback error: \(message)")
|
||
|
}
|
||
|
}
|
||
|
|
||
|
final class NWDeviceDiscoverer {
|
||
|
private var ctx: CastContext
|
||
|
private var fCastBrowser: NWBrowser
|
||
|
private var chromecastBrowser: NWBrowser
|
||
|
|
||
|
init(
|
||
|
context: CastContext,
|
||
|
onAdded: @escaping (FoundDevice) -> Void,
|
||
|
onRemoved: @escaping (NWEndpoint) -> Void,
|
||
|
) {
|
||
|
ctx = context
|
||
|
fCastBrowser = NWBrowser(
|
||
|
for: .bonjourWithTXTRecord(type: "_fcast._tcp", domain: nil),
|
||
|
using: .tcp
|
||
|
)
|
||
|
chromecastBrowser = NWBrowser(
|
||
|
for: .bonjourWithTXTRecord(type: "_googlecast._tcp", domain: nil),
|
||
|
using: .tcp
|
||
|
)
|
||
|
|
||
|
fCastBrowser.browseResultsChangedHandler = { newResults, changes in
|
||
|
for result in changes {
|
||
|
switch result {
|
||
|
case .added(let added):
|
||
|
if case .service(let name, _, _, _) = added.endpoint {
|
||
|
onAdded(
|
||
|
FoundDevice(
|
||
|
name: name,
|
||
|
endpoint: added.endpoint,
|
||
|
proto: ProtocolType.fCast
|
||
|
)
|
||
|
)
|
||
|
}
|
||
|
case .removed(let removed):
|
||
|
onRemoved(removed.endpoint)
|
||
|
default:
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
chromecastBrowser.browseResultsChangedHandler = { newResults, changes in
|
||
|
for result in changes {
|
||
|
switch result {
|
||
|
case .added(let added):
|
||
|
if case .service(var name, _, _, _) = added.endpoint {
|
||
|
if case .bonjour(let txt) = added.metadata,
|
||
|
let maybeFriendlyNameData = txt.getEntry(for: "fn"),
|
||
|
let friendlyNameData = maybeFriendlyNameData.data,
|
||
|
let friendlyName = String(
|
||
|
data: friendlyNameData,
|
||
|
encoding: .utf8
|
||
|
)
|
||
|
{
|
||
|
name = friendlyName
|
||
|
}
|
||
|
onAdded(
|
||
|
FoundDevice(
|
||
|
name: name,
|
||
|
endpoint: added.endpoint,
|
||
|
proto: ProtocolType.chromecast
|
||
|
)
|
||
|
)
|
||
|
}
|
||
|
case .removed(let removed):
|
||
|
onRemoved(removed.endpoint)
|
||
|
default:
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
fCastBrowser.start(queue: .main)
|
||
|
chromecastBrowser.start(queue: .main)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
struct ContentView: View {
|
||
|
@ObservedObject var dataModel: DataModel
|
||
|
var castContext: CastContext
|
||
|
var discoverer: NWDeviceDiscoverer
|
||
|
@State var activeDevice: CastingDevice? = nil
|
||
|
var eventHandler: DevEventHandler
|
||
|
@State var selectedMediaItem: PhotosPickerItem? = nil
|
||
|
@State var isImportingFile = false
|
||
|
@State var isShowingMediaPicker = false
|
||
|
@State var activeFileHandle: FileHandle? = nil
|
||
|
var fileServer: FileServer
|
||
|
@State var isShowingErrorAlert = false
|
||
|
@State var errorAlertMessage = ""
|
||
|
|
||
|
init(data: DataModel) throws {
|
||
|
initLogger(levelFilter: LogLevelFilter.debug)
|
||
|
dataModel = data
|
||
|
castContext = try CastContext()
|
||
|
fileServer = castContext.startFileServer()
|
||
|
discoverer = NWDeviceDiscoverer(
|
||
|
context: castContext,
|
||
|
onAdded: { found in
|
||
|
data.devices.append(found)
|
||
|
},
|
||
|
onRemoved: { endpoint in
|
||
|
data.devices.removeAll { it in
|
||
|
it.endpoint == endpoint
|
||
|
}
|
||
|
}
|
||
|
)
|
||
|
eventHandler = DevEventHandler(
|
||
|
onStateChanged: { state in
|
||
|
switch state {
|
||
|
case .connected(usedRemoteAddr: _, let localAddr):
|
||
|
DispatchQueue.main.async {
|
||
|
data.sheetState = SheetState.connected
|
||
|
data.usedLocalAddress = localAddr
|
||
|
}
|
||
|
default:
|
||
|
break
|
||
|
}
|
||
|
},
|
||
|
dataModel: data,
|
||
|
)
|
||
|
}
|
||
|
|
||
|
var body: some View {
|
||
|
NavigationStack {
|
||
|
VStack {
|
||
|
if activeDevice != nil {
|
||
|
Button("Cast local file") {
|
||
|
isShowingMediaPicker.toggle()
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
.padding()
|
||
|
.sheet(isPresented: $isShowingMediaPicker) {
|
||
|
MediaPicker { contentType, localFileURL in
|
||
|
if let handle = try? FileHandle(
|
||
|
forReadingFrom: localFileURL
|
||
|
) {
|
||
|
self.activeFileHandle = handle
|
||
|
Task {
|
||
|
if let activeDevice = self.activeDevice,
|
||
|
let usedLocalAddress = dataModel
|
||
|
.usedLocalAddress
|
||
|
{
|
||
|
do {
|
||
|
let entry = try self.fileServer.serveFile(
|
||
|
fd: handle.fileDescriptor
|
||
|
)
|
||
|
let url =
|
||
|
"http://\(urlFormatIpAddr(addr: usedLocalAddress)):\(entry.port)/\(entry.location)"
|
||
|
try activeDevice.load(request: .url(contentType: contentType, url: url))
|
||
|
} catch {
|
||
|
print("Failed to serve file")
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
} onError: { message in
|
||
|
errorAlertMessage = message
|
||
|
isShowingErrorAlert.toggle()
|
||
|
}
|
||
|
}
|
||
|
.alert(errorAlertMessage, isPresented: $isShowingErrorAlert) {
|
||
|
Button("OK", role: .cancel) {}
|
||
|
}
|
||
|
.toolbar {
|
||
|
Button(action: {
|
||
|
dataModel.isShowingSheet.toggle()
|
||
|
}) {
|
||
|
Image("chromecast-icon")
|
||
|
.renderingMode(.template)
|
||
|
.resizable()
|
||
|
.scaledToFit()
|
||
|
.frame(maxWidth: 64)
|
||
|
}
|
||
|
}
|
||
|
.sheet(isPresented: $dataModel.isShowingSheet) {
|
||
|
switch dataModel.sheetState {
|
||
|
case .deviceList:
|
||
|
DeviceList(
|
||
|
devices: dataModel.devices,
|
||
|
onConnect: { device in
|
||
|
dataModel.sheetState = SheetState.connecting(
|
||
|
deviceName: device.name
|
||
|
)
|
||
|
Task {
|
||
|
let conn = NWConnection(
|
||
|
to: device.endpoint,
|
||
|
using: .tcp
|
||
|
)
|
||
|
conn.stateUpdateHandler = { state in
|
||
|
switch state {
|
||
|
case .ready:
|
||
|
if let innerEndpoint = conn.currentPath?
|
||
|
.remoteEndpoint,
|
||
|
case .hostPort(let host, let port) =
|
||
|
innerEndpoint
|
||
|
{
|
||
|
switch host {
|
||
|
default:
|
||
|
break
|
||
|
}
|
||
|
let address: IpAddr
|
||
|
switch host {
|
||
|
case .ipv4(let addr):
|
||
|
let raw = addr.rawValue
|
||
|
address = IpAddr.v4(
|
||
|
o1: raw[0],
|
||
|
o2: raw[1],
|
||
|
o3: raw[2],
|
||
|
o4: raw[3]
|
||
|
)
|
||
|
case .ipv6(let addr):
|
||
|
let raw = addr.rawValue
|
||
|
address = IpAddr.v6(
|
||
|
o1: raw[0],
|
||
|
o2: raw[1],
|
||
|
o3: raw[2],
|
||
|
o4: raw[3],
|
||
|
o5: raw[4],
|
||
|
o6: raw[5],
|
||
|
o7: raw[6],
|
||
|
o8: raw[7],
|
||
|
o9: raw[8],
|
||
|
o10: raw[9],
|
||
|
o11: raw[10],
|
||
|
o12: raw[11],
|
||
|
o13: raw[12],
|
||
|
o14: raw[13],
|
||
|
o15: raw[14],
|
||
|
o16: raw[15],
|
||
|
scopeId: UInt32(addr.interface?.index ?? 0)
|
||
|
)
|
||
|
default:
|
||
|
DispatchQueue.main.async {
|
||
|
dataModel.sheetState =
|
||
|
SheetState.failedToConnect(
|
||
|
deviceName: device.name,
|
||
|
reason:
|
||
|
"No address available"
|
||
|
)
|
||
|
}
|
||
|
return
|
||
|
}
|
||
|
let info = DeviceInfo(
|
||
|
name: device.name,
|
||
|
protocol: device.proto,
|
||
|
addresses: [address],
|
||
|
port: port.rawValue
|
||
|
)
|
||
|
activeDevice =
|
||
|
castContext.createDeviceFromInfo(
|
||
|
info: info
|
||
|
)
|
||
|
do {
|
||
|
try activeDevice?.connect(
|
||
|
appInfo: nil,
|
||
|
eventHandler: eventHandler
|
||
|
)
|
||
|
} catch {
|
||
|
DispatchQueue.main.async {
|
||
|
dataModel.sheetState =
|
||
|
SheetState.failedToConnect(
|
||
|
deviceName: device.name,
|
||
|
reason: "Unknown"
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
default:
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
conn.start(queue: .global())
|
||
|
}
|
||
|
},
|
||
|
onConnectScanned: { scannedDeviceInfo in
|
||
|
activeDevice =
|
||
|
castContext.createDeviceFromInfo(
|
||
|
info: scannedDeviceInfo
|
||
|
)
|
||
|
do {
|
||
|
try activeDevice?.connect(
|
||
|
appInfo: nil,
|
||
|
eventHandler: eventHandler
|
||
|
)
|
||
|
} catch {
|
||
|
DispatchQueue.main.async {
|
||
|
dataModel.sheetState =
|
||
|
SheetState.failedToConnect(
|
||
|
deviceName: scannedDeviceInfo.name,
|
||
|
reason: "Unknown"
|
||
|
)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
)
|
||
|
.presentationDetents([.medium, .large])
|
||
|
case .connecting(let deviceName):
|
||
|
VStack {
|
||
|
ProgressView("Connecting to \(deviceName)")
|
||
|
.progressViewStyle(CircularProgressViewStyle())
|
||
|
Button(action: {
|
||
|
}) {
|
||
|
Text("Cancel")
|
||
|
}
|
||
|
}
|
||
|
.presentationDetents([.medium, .large])
|
||
|
case .failedToConnect(let deviceName, let reason):
|
||
|
VStack {
|
||
|
Text("Failed to connect to \(deviceName)")
|
||
|
Text("Reason: \(reason)")
|
||
|
}
|
||
|
.presentationDetents([.medium])
|
||
|
.onDisappear {
|
||
|
dataModel.sheetState = SheetState.deviceList
|
||
|
}
|
||
|
case .connected:
|
||
|
VStack {
|
||
|
if let devName = activeDevice?.name() {
|
||
|
(Text("Connected to ") + Text(devName).bold())
|
||
|
.padding(.top)
|
||
|
}
|
||
|
|
||
|
Spacer()
|
||
|
|
||
|
Text("Position")
|
||
|
Slider(
|
||
|
value: $dataModel.time,
|
||
|
in: 0.0...dataModel.duration,
|
||
|
onEditingChanged: { editing in
|
||
|
if !editing {
|
||
|
do {
|
||
|
try activeDevice?.seek(
|
||
|
timeSeconds: dataModel.time
|
||
|
)
|
||
|
} catch {
|
||
|
print("Failed to seek")
|
||
|
}
|
||
|
}
|
||
|
},
|
||
|
)
|
||
|
|
||
|
Text("Volume")
|
||
|
Slider(
|
||
|
value: $dataModel.volume,
|
||
|
in: 0.0...1.0,
|
||
|
onEditingChanged: { editing in
|
||
|
if !editing {
|
||
|
do {
|
||
|
try activeDevice?.changeVolume(
|
||
|
volume: dataModel.volume
|
||
|
)
|
||
|
} catch {
|
||
|
print("Failed to change volume")
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
)
|
||
|
|
||
|
HStack {
|
||
|
Spacer()
|
||
|
|
||
|
Button(action: {
|
||
|
do {
|
||
|
try activeDevice?.pausePlayback()
|
||
|
} catch {
|
||
|
print("Failed to pause playback")
|
||
|
}
|
||
|
}) {
|
||
|
Image(systemName: "pause").font(.system(size: 42))
|
||
|
}
|
||
|
|
||
|
Spacer()
|
||
|
|
||
|
Button(action: {
|
||
|
do {
|
||
|
try activeDevice?.resumePlayback()
|
||
|
} catch {
|
||
|
print("Failed to resume playback")
|
||
|
}
|
||
|
}) {
|
||
|
Image(systemName: "play").font(.system(size: 42))
|
||
|
}
|
||
|
|
||
|
Spacer()
|
||
|
|
||
|
Button(action: {
|
||
|
do {
|
||
|
try activeDevice?.stopPlayback()
|
||
|
} catch {
|
||
|
print("Failed to stop playback")
|
||
|
}
|
||
|
}) {
|
||
|
Image(systemName: "stop").font(.system(size: 42))
|
||
|
}
|
||
|
|
||
|
Spacer()
|
||
|
}
|
||
|
|
||
|
Spacer()
|
||
|
|
||
|
Button("Disconnect") {
|
||
|
do {
|
||
|
try activeDevice?.disconnect()
|
||
|
} catch {
|
||
|
print("Failed to disconnect device")
|
||
|
}
|
||
|
activeDevice = nil
|
||
|
dataModel.sheetState = SheetState.deviceList
|
||
|
}
|
||
|
.padding()
|
||
|
}
|
||
|
.presentationDetents([.medium, .large])
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
struct DeviceList: View {
|
||
|
var devices: [FoundDevice]
|
||
|
var onConnect: (FoundDevice) -> Void
|
||
|
var onConnectScanned: (DeviceInfo) -> Void
|
||
|
@State var isPresentingQrScanner = false
|
||
|
|
||
|
var body: some View {
|
||
|
VStack {
|
||
|
List(devices, id: \.name) { device in
|
||
|
Button(action: {
|
||
|
onConnect(device)
|
||
|
}) {
|
||
|
HStack {
|
||
|
// TODO: change these icons
|
||
|
switch device.proto {
|
||
|
case .chromecast:
|
||
|
Image("chromecast-icon")
|
||
|
.renderingMode(.template)
|
||
|
.resizable()
|
||
|
.scaledToFit()
|
||
|
.frame(maxWidth: 32)
|
||
|
default:
|
||
|
Image(systemName: "questionmark.app.dashed")
|
||
|
}
|
||
|
Text(device.name)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
Text("Not seeing your receiver?")
|
||
|
|
||
|
Button("Scan QR", systemImage: "qrcode.viewfinder") {
|
||
|
isPresentingQrScanner.toggle()
|
||
|
}
|
||
|
}
|
||
|
.sheet(isPresented: $isPresentingQrScanner) {
|
||
|
CodeScannerView(codeTypes: [.qr]) { response in
|
||
|
if case let .success(result) = response {
|
||
|
isPresentingQrScanner = false
|
||
|
if let deviceInfo = deviceInfoFromUrl(url: result.string) {
|
||
|
onConnectScanned(deviceInfo)
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
struct MediaPicker: UIViewControllerRepresentable {
|
||
|
var onComplete: (String, URL) -> Void
|
||
|
var onError: (String) -> Void
|
||
|
|
||
|
func makeCoordinator() -> Coordinator {
|
||
|
Coordinator(onComplete: onComplete, onError: onError)
|
||
|
}
|
||
|
|
||
|
func makeUIViewController(context: Context) -> PHPickerViewController {
|
||
|
var config = PHPickerConfiguration(photoLibrary: .shared())
|
||
|
config.filter = .any(of: [.images, .videos])
|
||
|
config.selectionLimit = 1
|
||
|
|
||
|
let picker = PHPickerViewController(configuration: config)
|
||
|
picker.delegate = context.coordinator
|
||
|
return picker
|
||
|
}
|
||
|
|
||
|
func updateUIViewController(
|
||
|
_ uiViewController: PHPickerViewController,
|
||
|
context: Context
|
||
|
) {}
|
||
|
|
||
|
class Coordinator: NSObject, PHPickerViewControllerDelegate {
|
||
|
let onComplete: (String, URL) -> Void
|
||
|
let onError: (String) -> Void
|
||
|
|
||
|
init(onComplete: @escaping (String, URL) -> Void, onError: @escaping (String) -> Void) {
|
||
|
self.onComplete = onComplete
|
||
|
self.onError = onError
|
||
|
}
|
||
|
|
||
|
func picker(
|
||
|
_ picker: PHPickerViewController,
|
||
|
didFinishPicking results: [PHPickerResult]
|
||
|
) {
|
||
|
picker.dismiss(animated: true)
|
||
|
guard let item = results.first?.itemProvider else { return }
|
||
|
print(item.registeredContentTypes)
|
||
|
guard
|
||
|
var contentType = item
|
||
|
.registeredContentTypes
|
||
|
.makeIterator()
|
||
|
.map({ it in return it.preferredMIMEType })
|
||
|
.filter({ it in it != nil })
|
||
|
.first ?? "application/octet-stream"
|
||
|
else {
|
||
|
print("Unable to get content type")
|
||
|
return
|
||
|
}
|
||
|
if contentType == "video/quicktime" {
|
||
|
contentType = "video/mp4"
|
||
|
}
|
||
|
|
||
|
let matchingTypes = [
|
||
|
UTType.image.identifier,
|
||
|
UTType.movie.identifier,
|
||
|
]
|
||
|
for typeId in matchingTypes {
|
||
|
if item.hasItemConformingToTypeIdentifier(typeId) {
|
||
|
item.loadFileRepresentation(forTypeIdentifier: typeId) {
|
||
|
tempURL,
|
||
|
maybeError in
|
||
|
if let error = maybeError {
|
||
|
self.onError(error.localizedDescription)
|
||
|
return
|
||
|
}
|
||
|
guard let tempURL = tempURL else {
|
||
|
self.onError("Temporary URL is missing")
|
||
|
return
|
||
|
}
|
||
|
self.onComplete(contentType, tempURL)
|
||
|
}
|
||
|
break
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|