initial prototype: synced mp3 playback
20
.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
.DS_Store
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
DerivedData/
|
||||
|
||||
# SwiftPM
|
||||
.build/
|
||||
|
||||
# CocoaPods
|
||||
Pods/
|
||||
|
||||
# Carthage
|
||||
Carthage/Build/
|
||||
|
||||
# Xcode
|
||||
*.xcworkspace/xcuserdata/
|
||||
*.xcodeproj/xcuserdata/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
480
CoopRunning.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
@@ -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>
|
||||
7
CoopRunning.xcworkspace/contents.xcworkspacedata
generated
Normal 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
@@ -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
CoopRunning/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
CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-1024.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
6
CoopRunning/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
129
CoopRunning/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
CoopRunning/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("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))
|
||||
)
|
||||
}
|
||||
}
|
||||
13
CoopRunning/CoopRunningApp.swift
Normal 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
@@ -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>
|
||||
29
CoopRunning/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="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>
|
||||
70
CoopRunning/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("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
|
||||
)
|
||||
}
|
||||
}
|
||||
162
CoopRunning/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 = "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) {}
|
||||
}
|
||||
119
CoopRunning/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
CoopRunning/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
|
||||
}
|
||||
}
|
||||
9
CoopRunningTests/CoopRunningTests.swift
Normal 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
@@ -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
@@ -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"
|
||||