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 = [] 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 }