snapshot current state before gitea sync
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user