snapshot current state before gitea sync
242
RunPlus/AppModel.swift
Normal 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
|
||||
}
|
||||
74
RunPlus/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
RunPlus/Assets.xcassets/AppIcon.appiconset/Icon-1024.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
RunPlus/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
RunPlus/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
RunPlus/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
RunPlus/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
RunPlus/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
RunPlus/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
RunPlus/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
RunPlus/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
RunPlus/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
RunPlus/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
6
RunPlus/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
129
RunPlus/AudioPlayerController.swift
Normal 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
@@ -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
@@ -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>
|
||||
29
RunPlus/LaunchScreen.storyboard
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct RunPlusApp: App {
|
||||
@StateObject private var appModel = AppModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(appModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
119
RunPlus/SessionMessage.swift
Normal 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
@@ -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
|
||||
}
|
||||
}
|
||||