initial prototype: synced mp3 playback

This commit is contained in:
2026-02-10 12:08:04 +01:00
commit 4b14d06bc8
30 changed files with 1784 additions and 0 deletions

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
.DS_Store
xcuserdata/
*.xcuserstate
DerivedData/
# SwiftPM
.build/
# CocoaPods
Pods/
# Carthage
Carthage/Build/
# Xcode
*.xcworkspace/xcuserdata/
*.xcodeproj/xcuserdata/
# Logs
*.log

View File

@@ -0,0 +1,480 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
1C5A896A43530D81174546BA /* CoopRunningTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7972CAD1B5B5FB6F3CBB4DC /* CoopRunningTests.swift */; };
2DDFF6B9DA48824AB7E348F4 /* SessionMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989EE81E50A74AADB156B2F1 /* SessionMessage.swift */; };
4CC684E41D1241CE42FD7348 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0C06EA24EED7898D3555276 /* ContentView.swift */; };
5A81A364169B5FCD120F61FC /* PeerSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02763B5D5452C1CC1B93FD2B /* PeerSession.swift */; };
7D7B85F058BAFB619CCCE687 /* SyncClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E882038912A0EC724092D5C /* SyncClock.swift */; };
8CB2509F6A60D8C785CD8F5F /* CoopRunningApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF044E360A40EA66C3AFE6AB /* CoopRunningApp.swift */; };
A0AA73D0AF4BDA2998897899 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 62CACEE84EEBE1E90C04433F /* LaunchScreen.storyboard */; };
A7E26605A0C2D722889DC978 /* AudioPlayerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3135CEE034E1997695E141E /* AudioPlayerController.swift */; };
BE79C4ECF76C74CA638B5E71 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE69B1849CC70C66B4408E8D /* AppModel.swift */; };
D3F24682D211B5C8C703480A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0570D4F6BB0BB408E8A9A864 /* Assets.xcassets */; };
FBAD30B2745B7850ADC0BD10 /* LocalTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82B84A69F8BDB61FB49066A1 /* LocalTrack.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
D6C6FD151E41BF7E8F592C82 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 45108C7370BA21AEC5D4E16A /* Project object */;
proxyType = 1;
remoteGlobalIDString = 6D8086D1FAEE224EF0BE91DB;
remoteInfo = CoopRunning;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
02763B5D5452C1CC1B93FD2B /* PeerSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerSession.swift; sourceTree = "<group>"; };
0570D4F6BB0BB408E8A9A864 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
0E882038912A0EC724092D5C /* SyncClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncClock.swift; sourceTree = "<group>"; };
62CACEE84EEBE1E90C04433F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
642EFFBEF3D3144895D7F64A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
82B84A69F8BDB61FB49066A1 /* LocalTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalTrack.swift; sourceTree = "<group>"; };
955DE601980D43FC20175212 /* CoopRunningTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = CoopRunningTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
989EE81E50A74AADB156B2F1 /* SessionMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionMessage.swift; sourceTree = "<group>"; };
A0C06EA24EED7898D3555276 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
B7972CAD1B5B5FB6F3CBB4DC /* CoopRunningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoopRunningTests.swift; sourceTree = "<group>"; };
BE69B1849CC70C66B4408E8D /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
E3135CEE034E1997695E141E /* AudioPlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerController.swift; sourceTree = "<group>"; };
ED1954343D7F24F4CE237989 /* CoopRunning.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = CoopRunning.app; sourceTree = BUILT_PRODUCTS_DIR; };
FF044E360A40EA66C3AFE6AB /* CoopRunningApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoopRunningApp.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
0BCFD2D05DA713EECDE3A555 /* CoopRunning */ = {
isa = PBXGroup;
children = (
BE69B1849CC70C66B4408E8D /* AppModel.swift */,
0570D4F6BB0BB408E8A9A864 /* Assets.xcassets */,
E3135CEE034E1997695E141E /* AudioPlayerController.swift */,
A0C06EA24EED7898D3555276 /* ContentView.swift */,
FF044E360A40EA66C3AFE6AB /* CoopRunningApp.swift */,
642EFFBEF3D3144895D7F64A /* Info.plist */,
62CACEE84EEBE1E90C04433F /* LaunchScreen.storyboard */,
82B84A69F8BDB61FB49066A1 /* LocalTrack.swift */,
02763B5D5452C1CC1B93FD2B /* PeerSession.swift */,
989EE81E50A74AADB156B2F1 /* SessionMessage.swift */,
0E882038912A0EC724092D5C /* SyncClock.swift */,
);
path = CoopRunning;
sourceTree = "<group>";
};
6F5BDF622927472CD32D0CD8 = {
isa = PBXGroup;
children = (
0BCFD2D05DA713EECDE3A555 /* CoopRunning */,
927D95C70CE5ED7ADB2C3F20 /* CoopRunningTests */,
CBC491D93FB088E0E6148B1A /* Products */,
);
sourceTree = "<group>";
};
927D95C70CE5ED7ADB2C3F20 /* CoopRunningTests */ = {
isa = PBXGroup;
children = (
B7972CAD1B5B5FB6F3CBB4DC /* CoopRunningTests.swift */,
);
path = CoopRunningTests;
sourceTree = "<group>";
};
CBC491D93FB088E0E6148B1A /* Products */ = {
isa = PBXGroup;
children = (
ED1954343D7F24F4CE237989 /* CoopRunning.app */,
955DE601980D43FC20175212 /* CoopRunningTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
6D8086D1FAEE224EF0BE91DB /* CoopRunning */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5E860FBA956CBCBD03474441 /* Build configuration list for PBXNativeTarget "CoopRunning" */;
buildPhases = (
D35AAFE1ED99AD91623FC374 /* Sources */,
04C5D543DEB4F3CC401E7D42 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = CoopRunning;
packageProductDependencies = (
);
productName = CoopRunning;
productReference = ED1954343D7F24F4CE237989 /* CoopRunning.app */;
productType = "com.apple.product-type.application";
};
7176FDC3954B3226DB5DEE48 /* CoopRunningTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 2322A6CDF4FC2AE59DD780B1 /* Build configuration list for PBXNativeTarget "CoopRunningTests" */;
buildPhases = (
A54C28822F541CFCD33553AE /* Sources */,
);
buildRules = (
);
dependencies = (
6D8868D6D8F73E641CA58B0F /* PBXTargetDependency */,
);
name = CoopRunningTests;
packageProductDependencies = (
);
productName = CoopRunningTests;
productReference = 955DE601980D43FC20175212 /* CoopRunningTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
45108C7370BA21AEC5D4E16A /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
6D8086D1FAEE224EF0BE91DB = {
DevelopmentTeam = NG5W75WE8U;
ProvisioningStyle = Automatic;
};
7176FDC3954B3226DB5DEE48 = {
DevelopmentTeam = NG5W75WE8U;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 3BCFE323CCC2757AAFD6F452 /* Build configuration list for PBXProject "CoopRunning" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 6F5BDF622927472CD32D0CD8;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
projectDirPath = "";
projectRoot = "";
targets = (
6D8086D1FAEE224EF0BE91DB /* CoopRunning */,
7176FDC3954B3226DB5DEE48 /* CoopRunningTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
04C5D543DEB4F3CC401E7D42 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D3F24682D211B5C8C703480A /* Assets.xcassets in Resources */,
A0AA73D0AF4BDA2998897899 /* LaunchScreen.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
A54C28822F541CFCD33553AE /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1C5A896A43530D81174546BA /* CoopRunningTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
D35AAFE1ED99AD91623FC374 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
BE79C4ECF76C74CA638B5E71 /* AppModel.swift in Sources */,
A7E26605A0C2D722889DC978 /* AudioPlayerController.swift in Sources */,
4CC684E41D1241CE42FD7348 /* ContentView.swift in Sources */,
8CB2509F6A60D8C785CD8F5F /* CoopRunningApp.swift in Sources */,
FBAD30B2745B7850ADC0BD10 /* LocalTrack.swift in Sources */,
5A81A364169B5FCD120F61FC /* PeerSession.swift in Sources */,
2DDFF6B9DA48824AB7E348F4 /* SessionMessage.swift in Sources */,
7D7B85F058BAFB619CCCE687 /* SyncClock.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
6D8868D6D8F73E641CA58B0F /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 6D8086D1FAEE224EF0BE91DB /* CoopRunning */;
targetProxy = D6C6FD151E41BF7E8F592C82 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
2A934AD016F05C8A94BF4DA8 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
LOCALIZED_STRING_SWIFT_SYMBOLS = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_STRING_CATALOGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CoopRunning.app/CoopRunning";
};
name = Debug;
};
5FDDE7CAF9D800EFEF79CEAB /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
INFOPLIST_FILE = CoopRunning/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZED_STRING_SWIFT_SYMBOLS = YES;
PRODUCT_NAME = CoopRunning;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_STRING_CATALOGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
84DDC04A355E486EE89AC7F8 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
LOCALIZED_STRING_SWIFT_SYMBOLS = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_STRING_CATALOGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CoopRunning.app/CoopRunning";
};
name = Release;
};
B10B4E0B66978D394F8DD3C9 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
INFOPLIST_FILE = CoopRunning/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZED_STRING_SWIFT_SYMBOLS = YES;
PRODUCT_NAME = CoopRunning;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_STRING_CATALOGS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
DE756993C972E7F7EDB4067D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = NG5W75WE8U;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CoopRunning/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZED_STRING_SWIFT_SYMBOLS = YES;
MARKETING_VERSION = 0.1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = de.felixfoertsch.cooprunning;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRING_CATALOGS = YES;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
F630219557065594A62AFA33 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS = YES;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = NG5W75WE8U;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = CoopRunning/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
LOCALIZED_STRING_SWIFT_SYMBOLS = YES;
MARKETING_VERSION = 0.1.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = de.felixfoertsch.cooprunning;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_STRING_CATALOGS = YES;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
2322A6CDF4FC2AE59DD780B1 /* Build configuration list for PBXNativeTarget "CoopRunningTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
2A934AD016F05C8A94BF4DA8 /* Debug */,
84DDC04A355E486EE89AC7F8 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
3BCFE323CCC2757AAFD6F452 /* Build configuration list for PBXProject "CoopRunning" */ = {
isa = XCConfigurationList;
buildConfigurations = (
DE756993C972E7F7EDB4067D /* Debug */,
F630219557065594A62AFA33 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
5E860FBA956CBCBD03474441 /* Build configuration list for PBXNativeTarget "CoopRunning" */ = {
isa = XCConfigurationList;
buildConfigurations = (
B10B4E0B66978D394F8DD3C9 /* Debug */,
5FDDE7CAF9D800EFEF79CEAB /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = 45108C7370BA21AEC5D4E16A /* Project object */;
}

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6D8086D1FAEE224EF0BE91DB"
BuildableName = "CoopRunning.app"
BlueprintName = "CoopRunning"
ReferencedContainer = "container:CoopRunning.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6D8086D1FAEE224EF0BE91DB"
BuildableName = "CoopRunning.app"
BlueprintName = "CoopRunning"
ReferencedContainer = "container:CoopRunning.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7176FDC3954B3226DB5DEE48"
BuildableName = "CoopRunningTests.xctest"
BlueprintName = "CoopRunningTests"
ReferencedContainer = "container:CoopRunning.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6D8086D1FAEE224EF0BE91DB"
BuildableName = "CoopRunning.app"
BlueprintName = "CoopRunning"
ReferencedContainer = "container:CoopRunning.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "6D8086D1FAEE224EF0BE91DB"
BuildableName = "CoopRunning.app"
BlueprintName = "CoopRunning"
ReferencedContainer = "container:CoopRunning.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:CoopRunning.xcodeproj">
</FileRef>
</Workspace>

242
CoopRunning/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
}
}

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("CoopRunning Sync")
.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))
)
}
}

View File

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

43
CoopRunning/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>CoopRunning</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>_cooprun-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="CoopRunning" 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>

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("CoopRunning", 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
)
}
}

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 = "cooprun-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) {}
}

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

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

View File

@@ -0,0 +1,9 @@
import XCTest
@testable import CoopRunning
final class CoopRunningTests: XCTestCase {
func testLibraryLoads() {
let library = LocalTrack.loadLibrary()
XCTAssertNotNil(library)
}
}

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
# CoopRunning Sync (MVP)
This is a minimal SwiftUI + MultipeerConnectivity prototype to sync **local MP3 playback** across iPhones. One device hosts and controls playback; peers join, receive the track file, and then sync playback.
## Setup (Xcode)
1. Create a new iOS App project in Xcode (SwiftUI, iOS 17+ or latest).
2. Replace the generated Swift files with the files in `CoopRunning/`.
3. Add these keys to your app's `Info.plist`:
- `NSLocalNetworkUsageDescription` = "Sync audio with nearby runners"
- `NSBonjourServices` (Array) with `_cooprun-sync._tcp`
- `NSBluetoothAlwaysUsageDescription` = "Find nearby runners"
## How It Works
- Host taps **Host**, peers tap **Join**.
- Host imports an MP3 via **Import MP3**, then taps **Send Track** to push it to peers.
- Host taps **Play** to broadcast a shared start time.
- Host can tap **Sync Now** for a quick clock sync.
## Notes
- This is a prototype using `AVAudioEngine` host-time scheduling plus uptime-based sync. It is good enough for group runs, but not sample-accurate.

82
project.yml Normal file
View File

@@ -0,0 +1,82 @@
name: CoopRunning
options:
bundleIdPrefix: de.felixfoertsch
deploymentTarget:
iOS: "26.0"
settings:
base:
SWIFT_VERSION: 5.9
DEVELOPMENT_ASSET_PATHS: ""
TARGETED_DEVICE_FAMILY: "1" # iPhone
INFOPLIST_FILE: CoopRunning/Info.plist
CODE_SIGN_STYLE: Automatic
PRODUCT_BUNDLE_IDENTIFIER: de.felixfoertsch.cooprunning
DEVELOPMENT_TEAM: NG5W75WE8U
CODE_SIGN_IDENTITY: "Apple Development"
PROVISIONING_PROFILE_SPECIFIER: ""
ENABLE_USER_SCRIPT_SANDBOXING: "YES"
ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS: "YES"
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: "YES"
LOCALIZED_STRING_SWIFT_SYMBOLS: "YES"
SWIFT_STRING_CATALOGS: "YES"
STRING_CATALOG_GENERATE_SYMBOLS: "YES"
MARKETING_VERSION: 0.1.0
CURRENT_PROJECT_VERSION: 1
GENERATE_INFOPLIST_FILE: NO
configs:
Debug: debug
Release: release
schemes:
CoopRunning:
build:
targets:
CoopRunning: all
test:
targets:
- CoopRunningTests
targets:
CoopRunning:
type: application
platform: iOS
sources:
- path: CoopRunning
resources:
- path: CoopRunning/LaunchScreen.storyboard
settings:
base:
PRODUCT_NAME: CoopRunning
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
LOCALIZED_STRING_SWIFT_SYMBOLS: "YES"
SWIFT_STRING_CATALOGS: "YES"
STRING_CATALOG_GENERATE_SYMBOLS: "YES"
info:
path: CoopRunning/Info.plist
properties:
CFBundleDisplayName: CoopRunning
LSRequiresIPhoneOS: true
UILaunchStoryboardName: LaunchScreen
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
NSLocalNetworkUsageDescription: "Sync audio with nearby runners"
NSBonjourServices:
- "_cooprun-sync._tcp"
NSBluetoothAlwaysUsageDescription: "Find nearby runners"
CoopRunningTests:
type: bundle.unit-test
platform: iOS
sources:
- path: CoopRunningTests
dependencies:
- target: CoopRunning
settings:
base:
LOCALIZED_STRING_SWIFT_SYMBOLS: "YES"
SWIFT_STRING_CATALOGS: "YES"
STRING_CATALOG_GENERATE_SYMBOLS: "YES"