mirror of
https://gitlab.com/futo-org/fcast.git
synced 2025-08-23 15:52:49 +00:00
Sender SDK
This commit is contained in:
parent
fdbefc63e0
commit
afc46f3022
147 changed files with 17638 additions and 114 deletions
605
sdk/sender/examples/ios/FCast Sender/ContentView.swift
Normal file
605
sdk/sender/examples/ios/FCast Sender/ContentView.swift
Normal file
|
@ -0,0 +1,605 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue