snapshot current state before gitea sync

This commit is contained in:
2026-02-18 10:50:25 +01:00
parent 4b14d06bc8
commit 4f6ff705cd
31 changed files with 228 additions and 220 deletions

242
RunPlus/AppModel.swift Normal file
View File

@@ -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
}

View File

@@ -0,0 +1,74 @@
{
"images" : [
{
"filename" : "Icon-20@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"filename" : "Icon-20@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"filename" : "Icon-29@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"filename" : "Icon-29@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"filename" : "Icon-40@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"filename" : "Icon-40@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"filename" : "Icon-60@2x.png",
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"filename" : "Icon-60@3x.png",
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"filename" : "Icon-76@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "76x76"
},
{
"filename" : "Icon-83.5@2x.png",
"idiom" : "ipad",
"scale" : "2x",
"size" : "83.5x83.5"
},
{
"filename" : "Icon-1024.png",
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,129 @@
import Foundation
import AVFoundation
import Darwin
final class AudioPlayerController: NSObject, ObservableObject {
@Published private(set) var isPlaying: Bool = false
@Published private(set) var playbackPosition: TimeInterval = 0
private let engine = AVAudioEngine()
private let playerNode = AVAudioPlayerNode()
private var audioFile: AVAudioFile?
private var progressTimer: Timer?
private var didConfigureSession = false
private var currentTrack: LocalTrack?
private var scheduleStartPosition: TimeInterval = 0
override init() {
super.init()
engine.attach(playerNode)
engine.connect(playerNode, to: engine.mainMixerNode, format: nil)
}
func play(track: LocalTrack, atUptime startUptime: TimeInterval, startPosition: TimeInterval) {
configureAudioSessionIfNeeded()
do {
let file = try AVAudioFile(forReading: track.url)
audioFile = file
currentTrack = track
scheduleStartPosition = startPosition
let startFrame = AVAudioFramePosition(startPosition * file.fileFormat.sampleRate)
let totalFrames = file.length
let framesLeft = max(AVAudioFrameCount(totalFrames - startFrame), 0)
if !engine.isRunning {
try engine.start()
}
playerNode.stop()
playerNode.scheduleSegment(
file,
startingFrame: startFrame,
frameCount: framesLeft,
at: AVAudioTime(hostTime: hostTime(forUptime: startUptime))
) { [weak self] in
DispatchQueue.main.async {
self?.isPlaying = false
self?.stopProgressTimer()
}
}
if !playerNode.isPlaying {
playerNode.play()
}
isPlaying = true
startProgressTimer()
} catch {
isPlaying = false
}
}
func pause() {
playerNode.pause()
isPlaying = false
stopProgressTimer()
}
func stop() {
playerNode.stop()
isPlaying = false
playbackPosition = 0
stopProgressTimer()
}
func seek(to position: TimeInterval) {
guard let track = currentTrack else { return }
play(track: track, atUptime: SyncClock.uptime() + 0.1, startPosition: position)
}
func correctDrift(targetPosition: TimeInterval, hostUptime: TimeInterval) {
guard let track = currentTrack else { return }
let targetNow = SyncClock.convert(hostUptime: hostUptime)
let expectedPosition = targetPosition + (SyncClock.uptime() - targetNow)
let drift = expectedPosition - playbackPosition
if abs(drift) > 0.15 {
play(track: track, atUptime: SyncClock.uptime() + 0.1, startPosition: expectedPosition)
}
}
private func startProgressTimer() {
stopProgressTimer()
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
guard let self else { return }
if let nodeTime = self.playerNode.lastRenderTime,
let playerTime = self.playerNode.playerTime(forNodeTime: nodeTime) {
let seconds = Double(playerTime.sampleTime) / playerTime.sampleRate
self.playbackPosition = self.scheduleStartPosition + seconds
self.isPlaying = self.playerNode.isPlaying
}
}
}
private func stopProgressTimer() {
progressTimer?.invalidate()
progressTimer = nil
}
private func configureAudioSessionIfNeeded() {
guard !didConfigureSession else { return }
do {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default)
try session.setActive(true)
didConfigureSession = true
} catch {
didConfigureSession = false
}
}
private func hostTime(forUptime startUptime: TimeInterval) -> UInt64 {
let nowUptime = SyncClock.uptime()
let delay = max(0, startUptime - nowUptime)
let hostDelay = AVAudioTime.hostTime(forSeconds: delay)
return mach_absolute_time() + hostDelay
}
}

160
RunPlus/ContentView.swift Normal file
View File

@@ -0,0 +1,160 @@
import SwiftUI
import UniformTypeIdentifiers
struct ContentView: View {
@EnvironmentObject private var appModel: AppModel
@State private var isImporting = false
var body: some View {
ZStack {
LinearGradient(
colors: [Color.blue.opacity(0.25), Color.cyan.opacity(0.18), Color.mint.opacity(0.12)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
ScrollView {
VStack(spacing: 16) {
header
card(roleSection)
card(librarySection)
card(playbackSection)
card(connectionSection)
}
.padding(20)
}
}
.fileImporter(
isPresented: $isImporting,
allowedContentTypes: [.mp3, .mpeg4Audio, .audio],
allowsMultipleSelection: false
) { result in
do {
let url = try result.get().first
guard let url else { return }
try appModel.importTrack(url: url)
} catch {
appModel.statusText = "Import failed: \(error.localizedDescription)"
}
}
}
private var header: some View {
VStack(spacing: 6) {
Text("Run+")
.font(.title2).bold()
Text(appModel.statusText)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
private var roleSection: some View {
HStack(spacing: 12) {
Button("Host") { appModel.host() }
.buttonStyle(.borderedProminent)
Button("Join") { appModel.join() }
.buttonStyle(.bordered)
Button("Stop") { appModel.stop() }
.buttonStyle(.bordered)
}
}
private var librarySection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Library")
.font(.headline)
Spacer()
Button("Import MP3") {
isImporting = true
}
}
if appModel.library.isEmpty {
Text("No local tracks yet.")
.foregroundStyle(.secondary)
} else {
Picker("Track", selection: Binding(get: {
appModel.selectedTrack
}, set: { newValue in
if let track = newValue { appModel.select(track: track) }
})) {
ForEach(appModel.library) { track in
Text(track.displayName).tag(Optional(track))
}
}
.pickerStyle(.menu)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var playbackSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Playback")
.font(.headline)
HStack(spacing: 12) {
Button(appModel.isPlaying ? "Pause" : "Play") {
appModel.togglePlay()
}
.buttonStyle(.borderedProminent)
.disabled(appModel.selectedTrack == nil || appModel.role != .host)
Button("Send Track") {
appModel.sendTrackToPeers()
}
.buttonStyle(.bordered)
.disabled(appModel.selectedTrack == nil || appModel.role != .host || !appModel.isConnected)
Button("Sync Now") {
appModel.syncNow()
}
.buttonStyle(.bordered)
.disabled(appModel.role != .host)
}
HStack {
Text("Position")
Slider(value: Binding(get: {
appModel.playbackPosition
}, set: { newValue in
appModel.seek(to: newValue)
}), in: 0...max(appModel.selectedTrack?.duration ?? 1, 1))
}
.disabled(appModel.selectedTrack == nil || appModel.role != .host)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var connectionSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Peers")
.font(.headline)
if appModel.peers.isEmpty {
Text("No peers yet")
.foregroundStyle(.secondary)
} else {
ForEach(appModel.peers) { peer in
Text(peer.name)
.font(.subheadline)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func card<V: View>(_ content: V) -> some View {
content
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.15))
)
}
}

43
RunPlus/Info.plist Normal file
View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Run+</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Find nearby runners</string>
<key>NSBonjourServices</key>
<array>
<string>_runplus-sync._tcp</string>
</array>
<key>NSLocalNetworkUsageDescription</key>
<string>Sync audio with nearby runners</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21701" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" initialViewController="01J-lp-oVM">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<scenes>
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="390" height="844"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Run+" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="1aC-zt-x8B">
<rect key="frame" x="120" y="412" width="150" height="20"/>
<fontDescription key="fontDescription" type="system" pointSize="17" weight="semibold"/>
<color key="textColor" systemColor="labelColor"/>
</label>
</subviews>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
<constraints>
<constraint firstItem="1aC-zt-x8B" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="IhJ-0C-9pW"/>
<constraint firstItem="1aC-zt-x8B" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="iLJ-yt-9u1"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="52" y="375"/>
</scene>
</scenes>
</document>

70
RunPlus/LocalTrack.swift Normal file
View File

@@ -0,0 +1,70 @@
import Foundation
import AVFoundation
struct LocalTrack: Identifiable, Hashable {
let id: UUID
let url: URL
let displayName: String
let duration: TimeInterval
static func libraryDirectory() -> URL {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let dir = docs.appendingPathComponent("RunPlus", isDirectory: true)
if !FileManager.default.fileExists(atPath: dir.path) {
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
return dir
}
static func loadLibrary() async -> [LocalTrack] {
let dir = libraryDirectory()
let urls = (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
var tracks: [LocalTrack] = []
for url in urls {
let asset = AVURLAsset(url: url)
let duration = (try? await asset.load(.duration).seconds) ?? 0
let track = LocalTrack(
id: UUID(uuidString: url.deletingPathExtension().lastPathComponent) ?? UUID(),
url: url,
displayName: url.deletingPathExtension().lastPathComponent,
duration: duration
)
tracks.append(track)
}
return tracks.sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
}
static func importTrack(from url: URL) async throws -> LocalTrack {
let dir = libraryDirectory()
let id = UUID()
let dest = dir.appendingPathComponent("\(id.uuidString).mp3")
let didStart = url.startAccessingSecurityScopedResource()
defer {
if didStart { url.stopAccessingSecurityScopedResource() }
}
try FileManager.default.copyItem(at: url, to: dest)
let duration = (try? await AVURLAsset(url: dest).load(.duration).seconds) ?? 0
return LocalTrack(
id: id,
url: dest,
displayName: url.deletingPathExtension().lastPathComponent,
duration: duration
)
}
static func importReceivedTrack(from url: URL, trackID: String, displayName: String) async throws -> LocalTrack {
let dir = libraryDirectory()
let dest = dir.appendingPathComponent("\(trackID).mp3")
if FileManager.default.fileExists(atPath: dest.path) {
try FileManager.default.removeItem(at: dest)
}
try FileManager.default.moveItem(at: url, to: dest)
let duration = (try? await AVURLAsset(url: dest).load(.duration).seconds) ?? 0
return LocalTrack(
id: UUID(uuidString: trackID) ?? UUID(),
url: dest,
displayName: displayName,
duration: duration
)
}
}

162
RunPlus/PeerSession.swift Normal file
View File

@@ -0,0 +1,162 @@
import Foundation
import MultipeerConnectivity
import UIKit
final class PeerSession: NSObject, ObservableObject {
@Published private(set) var peers: [PeerInfo] = []
@Published private(set) var isConnected: Bool = false
@Published private(set) var statusText: String = "Idle"
var onMessage: ((SessionMessage) -> Void)?
var onReceiveResource: ((String, URL) -> Void)?
private let serviceType = "runplus-sync"
private let myPeerID = MCPeerID(displayName: UIDevice.current.name)
private var session: MCSession!
private var advertiser: MCNearbyServiceAdvertiser?
private var browser: MCNearbyServiceBrowser?
// Host keeps per-peer offsets
private var peerOffsets: [MCPeerID: TimeInterval] = [:]
override init() {
super.init()
session = MCSession(peer: myPeerID, securityIdentity: nil, encryptionPreference: .required)
session.delegate = self
}
func startHosting() {
stop()
statusText = "Hosting"
advertiser = MCNearbyServiceAdvertiser(peer: myPeerID, discoveryInfo: nil, serviceType: serviceType)
advertiser?.delegate = self
advertiser?.startAdvertisingPeer()
}
func startJoining() {
stop()
statusText = "Browsing"
browser = MCNearbyServiceBrowser(peer: myPeerID, serviceType: serviceType)
browser?.delegate = self
browser?.startBrowsingForPeers()
}
func stop() {
advertiser?.stopAdvertisingPeer()
advertiser = nil
browser?.stopBrowsingForPeers()
browser = nil
session.disconnect()
peers = []
isConnected = false
statusText = "Idle"
peerOffsets = [:]
}
func broadcast(_ message: SessionMessage) {
guard !session.connectedPeers.isEmpty else { return }
send(message, to: session.connectedPeers)
}
func reply(_ message: SessionMessage) {
guard !session.connectedPeers.isEmpty else { return }
send(message, to: session.connectedPeers)
}
func updateOffset(hostUptime: TimeInterval, peerUptime: TimeInterval) {
for peer in session.connectedPeers {
let offset = hostUptime - peerUptime
peerOffsets[peer] = offset
}
}
func sendTrack(_ track: LocalTrack) {
guard !session.connectedPeers.isEmpty else { return }
let info = TrackInfoPayload(trackID: track.id.uuidString, displayName: track.displayName)
send(.trackInfo(info), to: session.connectedPeers)
for peer in session.connectedPeers {
let resourceName = "track:\(track.id.uuidString)"
session.sendResource(at: track.url, withName: resourceName, toPeer: peer) { [weak self] error in
if let error {
DispatchQueue.main.async {
self?.statusText = "Send track failed: \(error.localizedDescription)"
}
}
}
}
}
private func send(_ message: SessionMessage, to peers: [MCPeerID]) {
do {
let data = try JSONEncoder().encode(message)
try session.send(data, toPeers: peers, with: .reliable)
} catch {
statusText = "Send failed: \(error.localizedDescription)"
}
}
private func handle(_ data: Data, from peerID: MCPeerID) {
do {
let message = try JSONDecoder().decode(SessionMessage.self, from: data)
if case .syncRequest(let hostUptime) = message {
let peerUptime = SyncClock.uptime()
SyncClock.setHostOffset(hostUptime: hostUptime, peerUptime: peerUptime)
}
onMessage?(message)
} catch {
statusText = "Decode failed: \(error.localizedDescription)"
}
}
}
extension PeerSession: MCSessionDelegate {
func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
DispatchQueue.main.async {
self.peers = session.connectedPeers.map { PeerInfo(id: $0.displayName, name: $0.displayName) }
self.isConnected = !session.connectedPeers.isEmpty
switch state {
case .connected:
self.statusText = "Connected to \(peerID.displayName)"
self.send(.hello, to: [peerID])
case .connecting:
self.statusText = "Connecting to \(peerID.displayName)"
case .notConnected:
self.statusText = "Disconnected"
@unknown default:
self.statusText = "Unknown state"
}
}
}
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
DispatchQueue.main.async {
self.handle(data, from: peerID)
}
}
func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {}
func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {}
func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) {
guard error == nil, let localURL else { return }
DispatchQueue.main.async {
self.onReceiveResource?(resourceName, localURL)
}
}
}
extension PeerSession: MCNearbyServiceAdvertiserDelegate {
func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
invitationHandler(true, session)
}
}
extension PeerSession: MCNearbyServiceBrowserDelegate {
func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) {
browser.invitePeer(peerID, to: session, withContext: nil, timeout: 10)
}
func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {}
}

13
RunPlus/RunPlusApp.swift Normal file
View File

@@ -0,0 +1,13 @@
import SwiftUI
@main
struct RunPlusApp: App {
@StateObject private var appModel = AppModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(appModel)
}
}
}

View File

@@ -0,0 +1,119 @@
import Foundation
struct SyncPlayPayload: Codable {
let trackID: String
let startUptime: TimeInterval
let startPosition: TimeInterval
}
struct TrackInfoPayload: Codable {
let trackID: String
let displayName: String
}
enum SessionMessage: Codable {
case hello
case libraryRequest
case trackInfo(TrackInfoPayload)
case trackRequest(String)
case play(SyncPlayPayload)
case pause
case seek(TimeInterval)
case syncRequest(TimeInterval)
case syncResponse(hostUptime: TimeInterval, peerUptime: TimeInterval)
case driftCorrection(position: TimeInterval, hostUptime: TimeInterval)
private enum CodingKeys: String, CodingKey {
case type
case payload
case position
case hostUptime
case peerUptime
case trackID
case displayName
}
private enum MessageType: String, Codable {
case hello
case libraryRequest
case trackInfo
case trackRequest
case play
case pause
case seek
case syncRequest
case syncResponse
case driftCorrection
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(MessageType.self, forKey: .type)
switch type {
case .hello:
self = .hello
case .libraryRequest:
self = .libraryRequest
case .trackInfo:
let payload = try container.decode(TrackInfoPayload.self, forKey: .payload)
self = .trackInfo(payload)
case .trackRequest:
let trackID = try container.decode(String.self, forKey: .trackID)
self = .trackRequest(trackID)
case .play:
let payload = try container.decode(SyncPlayPayload.self, forKey: .payload)
self = .play(payload)
case .pause:
self = .pause
case .seek:
let position = try container.decode(TimeInterval.self, forKey: .position)
self = .seek(position)
case .syncRequest:
let hostUptime = try container.decode(TimeInterval.self, forKey: .hostUptime)
self = .syncRequest(hostUptime)
case .syncResponse:
let hostUptime = try container.decode(TimeInterval.self, forKey: .hostUptime)
let peerUptime = try container.decode(TimeInterval.self, forKey: .peerUptime)
self = .syncResponse(hostUptime: hostUptime, peerUptime: peerUptime)
case .driftCorrection:
let position = try container.decode(TimeInterval.self, forKey: .position)
let hostUptime = try container.decode(TimeInterval.self, forKey: .hostUptime)
self = .driftCorrection(position: position, hostUptime: hostUptime)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .hello:
try container.encode(MessageType.hello, forKey: .type)
case .libraryRequest:
try container.encode(MessageType.libraryRequest, forKey: .type)
case .trackInfo(let payload):
try container.encode(MessageType.trackInfo, forKey: .type)
try container.encode(payload, forKey: .payload)
case .trackRequest(let trackID):
try container.encode(MessageType.trackRequest, forKey: .type)
try container.encode(trackID, forKey: .trackID)
case .play(let payload):
try container.encode(MessageType.play, forKey: .type)
try container.encode(payload, forKey: .payload)
case .pause:
try container.encode(MessageType.pause, forKey: .type)
case .seek(let position):
try container.encode(MessageType.seek, forKey: .type)
try container.encode(position, forKey: .position)
case .syncRequest(let hostUptime):
try container.encode(MessageType.syncRequest, forKey: .type)
try container.encode(hostUptime, forKey: .hostUptime)
case .syncResponse(let hostUptime, let peerUptime):
try container.encode(MessageType.syncResponse, forKey: .type)
try container.encode(hostUptime, forKey: .hostUptime)
try container.encode(peerUptime, forKey: .peerUptime)
case .driftCorrection(let position, let hostUptime):
try container.encode(MessageType.driftCorrection, forKey: .type)
try container.encode(position, forKey: .position)
try container.encode(hostUptime, forKey: .hostUptime)
}
}
}

17
RunPlus/SyncClock.swift Normal file
View File

@@ -0,0 +1,17 @@
import Foundation
enum SyncClock {
private static var hostOffset: TimeInterval = 0
static func uptime() -> TimeInterval {
ProcessInfo.processInfo.systemUptime
}
static func setHostOffset(hostUptime: TimeInterval, peerUptime: TimeInterval) {
hostOffset = hostUptime - peerUptime
}
static func convert(hostUptime: TimeInterval) -> TimeInterval {
hostUptime - hostOffset
}
}