Files
run/RunPlus/AppModel.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
}