commit 4b14d06bc8d38e25cdc0f04a66a61e897c82c7f0 Author: Felix Förtsch Date: Tue Feb 10 12:08:04 2026 +0100 initial prototype: synced mp3 playback diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54fb563 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +.DS_Store +xcuserdata/ +*.xcuserstate +DerivedData/ + +# SwiftPM +.build/ + +# CocoaPods +Pods/ + +# Carthage +Carthage/Build/ + +# Xcode +*.xcworkspace/xcuserdata/ +*.xcodeproj/xcuserdata/ + +# Logs +*.log diff --git a/CoopRunning.xcodeproj/project.pbxproj b/CoopRunning.xcodeproj/project.pbxproj new file mode 100644 index 0000000..dab3c26 --- /dev/null +++ b/CoopRunning.xcodeproj/project.pbxproj @@ -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 = ""; }; + 0570D4F6BB0BB408E8A9A864 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 0E882038912A0EC724092D5C /* SyncClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncClock.swift; sourceTree = ""; }; + 62CACEE84EEBE1E90C04433F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + 642EFFBEF3D3144895D7F64A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 82B84A69F8BDB61FB49066A1 /* LocalTrack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalTrack.swift; sourceTree = ""; }; + 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 = ""; }; + A0C06EA24EED7898D3555276 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + B7972CAD1B5B5FB6F3CBB4DC /* CoopRunningTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoopRunningTests.swift; sourceTree = ""; }; + BE69B1849CC70C66B4408E8D /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + E3135CEE034E1997695E141E /* AudioPlayerController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerController.swift; sourceTree = ""; }; + 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 = ""; }; +/* 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 = ""; + }; + 6F5BDF622927472CD32D0CD8 = { + isa = PBXGroup; + children = ( + 0BCFD2D05DA713EECDE3A555 /* CoopRunning */, + 927D95C70CE5ED7ADB2C3F20 /* CoopRunningTests */, + CBC491D93FB088E0E6148B1A /* Products */, + ); + sourceTree = ""; + }; + 927D95C70CE5ED7ADB2C3F20 /* CoopRunningTests */ = { + isa = PBXGroup; + children = ( + B7972CAD1B5B5FB6F3CBB4DC /* CoopRunningTests.swift */, + ); + path = CoopRunningTests; + sourceTree = ""; + }; + CBC491D93FB088E0E6148B1A /* Products */ = { + isa = PBXGroup; + children = ( + ED1954343D7F24F4CE237989 /* CoopRunning.app */, + 955DE601980D43FC20175212 /* CoopRunningTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/CoopRunning.xcodeproj/xcshareddata/xcschemes/CoopRunning.xcscheme b/CoopRunning.xcodeproj/xcshareddata/xcschemes/CoopRunning.xcscheme new file mode 100644 index 0000000..ee2494b --- /dev/null +++ b/CoopRunning.xcodeproj/xcshareddata/xcschemes/CoopRunning.xcscheme @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CoopRunning.xcworkspace/contents.xcworkspacedata b/CoopRunning.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..619a0db --- /dev/null +++ b/CoopRunning.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/CoopRunning/AppModel.swift b/CoopRunning/AppModel.swift new file mode 100644 index 0000000..04c1a79 --- /dev/null +++ b/CoopRunning/AppModel.swift @@ -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 = [] + 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 +} diff --git a/CoopRunning/Assets.xcassets/AppIcon.appiconset/Contents.json b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..dac9c05 --- /dev/null +++ b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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 + } +} diff --git a/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-1024.png b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-1024.png new file mode 100644 index 0000000..1618f8d Binary files /dev/null and b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-1024.png differ diff --git a/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png new file mode 100644 index 0000000..3ad5672 Binary files /dev/null and b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-20@2x.png differ diff --git a/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png new file mode 100644 index 0000000..11ba84f Binary files /dev/null and b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-20@3x.png differ diff --git a/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png new file mode 100644 index 0000000..dc59049 Binary files /dev/null and b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-29@2x.png differ diff --git a/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png new file mode 100644 index 0000000..9f1d432 Binary files /dev/null and b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-29@3x.png differ diff --git a/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png new file mode 100644 index 0000000..a7468f1 Binary files /dev/null and b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-40@2x.png differ diff --git a/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png new file mode 100644 index 0000000..9002186 Binary files /dev/null and b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-40@3x.png differ diff --git a/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png new file mode 100644 index 0000000..9002186 Binary files /dev/null and b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png differ diff --git a/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png new file mode 100644 index 0000000..b5baff7 Binary files /dev/null and b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png differ diff --git a/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png new file mode 100644 index 0000000..d02420c Binary files /dev/null and b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png differ diff --git a/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png new file mode 100644 index 0000000..536b011 Binary files /dev/null and b/CoopRunning/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png differ diff --git a/CoopRunning/Assets.xcassets/Contents.json b/CoopRunning/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/CoopRunning/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CoopRunning/AudioPlayerController.swift b/CoopRunning/AudioPlayerController.swift new file mode 100644 index 0000000..425044b --- /dev/null +++ b/CoopRunning/AudioPlayerController.swift @@ -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 + } +} diff --git a/CoopRunning/ContentView.swift b/CoopRunning/ContentView.swift new file mode 100644 index 0000000..58cb391 --- /dev/null +++ b/CoopRunning/ContentView.swift @@ -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(_ 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)) + ) + } +} diff --git a/CoopRunning/CoopRunningApp.swift b/CoopRunning/CoopRunningApp.swift new file mode 100644 index 0000000..72b0d17 --- /dev/null +++ b/CoopRunning/CoopRunningApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct CoopRunningApp: App { + @StateObject private var appModel = AppModel() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(appModel) + } + } +} diff --git a/CoopRunning/Info.plist b/CoopRunning/Info.plist new file mode 100644 index 0000000..c2af882 --- /dev/null +++ b/CoopRunning/Info.plist @@ -0,0 +1,43 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + CoopRunning + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + NSBluetoothAlwaysUsageDescription + Find nearby runners + NSBonjourServices + + _cooprun-sync._tcp + + NSLocalNetworkUsageDescription + Sync audio with nearby runners + UILaunchStoryboardName + LaunchScreen + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/CoopRunning/LaunchScreen.storyboard b/CoopRunning/LaunchScreen.storyboard new file mode 100644 index 0000000..c59e532 --- /dev/null +++ b/CoopRunning/LaunchScreen.storyboard @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CoopRunning/LocalTrack.swift b/CoopRunning/LocalTrack.swift new file mode 100644 index 0000000..13f12eb --- /dev/null +++ b/CoopRunning/LocalTrack.swift @@ -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 + ) + } +} diff --git a/CoopRunning/PeerSession.swift b/CoopRunning/PeerSession.swift new file mode 100644 index 0000000..47a093c --- /dev/null +++ b/CoopRunning/PeerSession.swift @@ -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) {} +} diff --git a/CoopRunning/SessionMessage.swift b/CoopRunning/SessionMessage.swift new file mode 100644 index 0000000..dae3ba3 --- /dev/null +++ b/CoopRunning/SessionMessage.swift @@ -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) + } + } +} diff --git a/CoopRunning/SyncClock.swift b/CoopRunning/SyncClock.swift new file mode 100644 index 0000000..a86f6d5 --- /dev/null +++ b/CoopRunning/SyncClock.swift @@ -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 + } +} diff --git a/CoopRunningTests/CoopRunningTests.swift b/CoopRunningTests/CoopRunningTests.swift new file mode 100644 index 0000000..6827742 --- /dev/null +++ b/CoopRunningTests/CoopRunningTests.swift @@ -0,0 +1,9 @@ +import XCTest +@testable import CoopRunning + +final class CoopRunningTests: XCTestCase { + func testLibraryLoads() { + let library = LocalTrack.loadLibrary() + XCTAssertNotNil(library) + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..dcd8181 --- /dev/null +++ b/README.md @@ -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. diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..8bbe8f0 --- /dev/null +++ b/project.yml @@ -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"