243 lines
8.0 KiB
Swift
243 lines
8.0 KiB
Swift
import Foundation
|
|
import Combine
|
|
|
|
final class AppModel: ObservableObject {
|
|
@Published var role: SessionRole = .idle
|
|
@Published var isConnected: Bool = false
|
|
@Published var peers: [PeerInfo] = []
|
|
@Published var statusText: String = "Not connected"
|
|
|
|
@Published var library: [LocalTrack] = []
|
|
@Published var selectedTrack: LocalTrack? = nil
|
|
|
|
@Published var isPlaying: Bool = false
|
|
@Published var playbackPosition: TimeInterval = 0
|
|
|
|
private let audio = AudioPlayerController()
|
|
private let session = PeerSession()
|
|
private var cancellables: Set<AnyCancellable> = []
|
|
private var driftTimer: Timer?
|
|
private var pendingTrackNames: [String: String] = [:]
|
|
private var pendingPlay: SyncPlayPayload? = nil
|
|
|
|
init() {
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
let loaded = await LocalTrack.loadLibrary()
|
|
await MainActor.run {
|
|
self.library = loaded
|
|
self.selectedTrack = loaded.first
|
|
}
|
|
}
|
|
|
|
session.$peers
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: &$peers)
|
|
|
|
session.$isConnected
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] connected in
|
|
self?.isConnected = connected
|
|
if connected && self?.role == .host {
|
|
self?.syncNow()
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
session.$statusText
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: &$statusText)
|
|
|
|
audio.$isPlaying
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: &$isPlaying)
|
|
|
|
audio.$playbackPosition
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: &$playbackPosition)
|
|
|
|
session.onMessage = { [weak self] message in
|
|
self?.handle(message: message)
|
|
}
|
|
session.onReceiveResource = { [weak self] name, url in
|
|
self?.handleResource(name: name, url: url)
|
|
}
|
|
}
|
|
|
|
func host() {
|
|
role = .host
|
|
session.startHosting()
|
|
}
|
|
|
|
func join() {
|
|
role = .peer
|
|
session.startJoining()
|
|
}
|
|
|
|
func stop() {
|
|
role = .idle
|
|
session.stop()
|
|
audio.stop()
|
|
stopDriftTimer()
|
|
}
|
|
|
|
func importTrack(url: URL) throws {
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
let newTrack = try await LocalTrack.importTrack(from: url)
|
|
let loaded = await LocalTrack.loadLibrary()
|
|
await MainActor.run {
|
|
self.library = loaded
|
|
self.selectedTrack = newTrack
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
self.statusText = "Import failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func select(track: LocalTrack) {
|
|
selectedTrack = track
|
|
}
|
|
|
|
func togglePlay() {
|
|
guard let track = selectedTrack else { return }
|
|
|
|
if role == .host {
|
|
if audio.isPlaying {
|
|
audio.pause()
|
|
session.broadcast(.pause)
|
|
stopDriftTimer()
|
|
} else {
|
|
// Schedule a shared future start time to align peers.
|
|
let startDelay: TimeInterval = 2.0
|
|
let startUptime = SyncClock.uptime() + startDelay
|
|
let payload = SyncPlayPayload(
|
|
trackID: track.id.uuidString,
|
|
startUptime: startUptime,
|
|
startPosition: audio.playbackPosition
|
|
)
|
|
session.broadcast(.play(payload))
|
|
audio.play(track: track, atUptime: startUptime, startPosition: audio.playbackPosition)
|
|
startDriftTimer()
|
|
}
|
|
}
|
|
}
|
|
|
|
func seek(to position: TimeInterval) {
|
|
guard selectedTrack != nil else { return }
|
|
audio.seek(to: position)
|
|
|
|
if role == .host {
|
|
session.broadcast(.seek(position))
|
|
}
|
|
}
|
|
|
|
func syncNow() {
|
|
guard role == .host else { return }
|
|
session.broadcast(.syncRequest(SyncClock.uptime()))
|
|
}
|
|
|
|
func sendTrackToPeers() {
|
|
guard role == .host, let track = selectedTrack else { return }
|
|
session.sendTrack(track)
|
|
}
|
|
|
|
private func handle(message: SessionMessage) {
|
|
switch message {
|
|
case .hello:
|
|
return
|
|
case .libraryRequest:
|
|
return
|
|
case .trackInfo(let info):
|
|
pendingTrackNames[info.trackID] = info.displayName
|
|
case .trackRequest(let trackID):
|
|
guard role == .host else { return }
|
|
if let track = library.first(where: { $0.id.uuidString == trackID }) {
|
|
session.sendTrack(track)
|
|
}
|
|
case .play(let payload):
|
|
guard role == .peer else { return }
|
|
if let track = library.first(where: { $0.id.uuidString == payload.trackID }) {
|
|
selectedTrack = track
|
|
let localStart = SyncClock.convert(hostUptime: payload.startUptime)
|
|
audio.play(track: track, atUptime: localStart, startPosition: payload.startPosition)
|
|
} else {
|
|
pendingPlay = payload
|
|
session.broadcast(.trackRequest(payload.trackID))
|
|
statusText = "Missing track. Requested from host."
|
|
}
|
|
case .pause:
|
|
guard role == .peer else { return }
|
|
audio.pause()
|
|
case .seek(let position):
|
|
guard role == .peer else { return }
|
|
audio.seek(to: position)
|
|
case .syncRequest(let hostUptime):
|
|
guard role == .peer else { return }
|
|
session.reply(.syncResponse(hostUptime: hostUptime, peerUptime: SyncClock.uptime()))
|
|
case .syncResponse(let hostUptime, let peerUptime):
|
|
guard role == .host else { return }
|
|
session.updateOffset(hostUptime: hostUptime, peerUptime: peerUptime)
|
|
case .driftCorrection(let position, let hostUptime):
|
|
guard role == .peer else { return }
|
|
audio.correctDrift(targetPosition: position, hostUptime: hostUptime)
|
|
}
|
|
}
|
|
|
|
private func handleResource(name: String, url: URL) {
|
|
guard name.hasPrefix("track:") else { return }
|
|
let trackID = String(name.dropFirst("track:".count))
|
|
let displayName = pendingTrackNames[trackID] ?? "Track"
|
|
Task { [weak self] in
|
|
guard let self else { return }
|
|
do {
|
|
let newTrack = try await LocalTrack.importReceivedTrack(from: url, trackID: trackID, displayName: displayName)
|
|
let loaded = await LocalTrack.loadLibrary()
|
|
await MainActor.run {
|
|
self.library = loaded
|
|
self.selectedTrack = newTrack
|
|
self.statusText = "Received track: \(displayName)"
|
|
if let pending = self.pendingPlay, pending.trackID == trackID {
|
|
let localStart = SyncClock.convert(hostUptime: pending.startUptime)
|
|
self.audio.play(track: newTrack, atUptime: localStart, startPosition: pending.startPosition)
|
|
self.pendingPlay = nil
|
|
}
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
self.statusText = "Import failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func startDriftTimer() {
|
|
stopDriftTimer()
|
|
driftTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
|
|
guard let self, self.role == .host else { return }
|
|
let payload = (self.audio.playbackPosition, SyncClock.uptime())
|
|
self.session.broadcast(.driftCorrection(position: payload.0, hostUptime: payload.1))
|
|
}
|
|
}
|
|
|
|
private func stopDriftTimer() {
|
|
driftTimer?.invalidate()
|
|
driftTimer = nil
|
|
}
|
|
}
|
|
|
|
enum SessionRole: String {
|
|
case idle
|
|
case host
|
|
case peer
|
|
}
|
|
|
|
struct PeerInfo: Identifiable, Hashable {
|
|
let id: String
|
|
let name: String
|
|
}
|