commit 01088b112bfb0b0c4524075b947632657687425b Author: Artemon4ik8091 Date: Tue Jul 14 12:06:58 2026 +0300 first commit diff --git a/music player.xcodeproj/project.pbxproj b/music player.xcodeproj/project.pbxproj new file mode 100644 index 0000000..40f5045 --- /dev/null +++ b/music player.xcodeproj/project.pbxproj @@ -0,0 +1,1037 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 09192EFC2FF44C2700A9F43F /* music_playerWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09192EFB2FF44C2700A9F43F /* music_playerWatchApp.swift */; }; + 09192EFE2FF44C2700A9F43F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09192EFD2FF44C2700A9F43F /* ContentView.swift */; }; + 09192F002FF44C2900A9F43F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 09192EFF2FF44C2900A9F43F /* Assets.xcassets */; }; + 09192F032FF44C2900A9F43F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 09192F022FF44C2900A9F43F /* Preview Assets.xcassets */; }; + 09192F0D2FF44C2900A9F43F /* music_playerWatch_Watch_AppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09192F0C2FF44C2900A9F43F /* music_playerWatch_Watch_AppTests.swift */; }; + 09192F172FF44C2900A9F43F /* music_playerWatch_Watch_AppUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09192F162FF44C2900A9F43F /* music_playerWatch_Watch_AppUITests.swift */; }; + 09192F192FF44C2900A9F43F /* music_playerWatch_Watch_AppUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09192F182FF44C2900A9F43F /* music_playerWatch_Watch_AppUITestsLaunchTests.swift */; }; + 09192F1C2FF44C2900A9F43F /* music playerWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 09192EF92FF44C2700A9F43F /* music playerWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 09192F292FF44E6500A9F43F /* WatchConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09192F272FF44E6500A9F43F /* WatchConnectivityManager.swift */; }; + 09192F2A2FF44E6500A9F43F /* NowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09192F282FF44E6500A9F43F /* NowPlayingView.swift */; }; + 09192F2C2FF44EB100A9F43F /* PhoneConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09192F2B2FF44EB100A9F43F /* PhoneConnectivityManager.swift */; }; + 09E3FD242FEB19100073862C /* music_playerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09E3FD232FEB19100073862C /* music_playerApp.swift */; }; + 09E3FD262FEB19100073862C /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09E3FD252FEB19100073862C /* ContentView.swift */; }; + 09E3FD282FEB19120073862C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 09E3FD272FEB19120073862C /* Assets.xcassets */; }; + 09E3FD2B2FEB19120073862C /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 09E3FD2A2FEB19120073862C /* Preview Assets.xcassets */; }; + 09E3FD352FEB19120073862C /* music_playerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09E3FD342FEB19120073862C /* music_playerTests.swift */; }; + 09E3FD3F2FEB19120073862C /* music_playerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09E3FD3E2FEB19120073862C /* music_playerUITests.swift */; }; + 09E3FD412FEB19120073862C /* music_playerUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09E3FD402FEB19120073862C /* music_playerUITestsLaunchTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 09192F092FF44C2900A9F43F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 09E3FD182FEB19100073862C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 09192EF82FF44C2700A9F43F; + remoteInfo = "music playerWatch Watch App"; + }; + 09192F132FF44C2900A9F43F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 09E3FD182FEB19100073862C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 09192EF82FF44C2700A9F43F; + remoteInfo = "music playerWatch Watch App"; + }; + 09192F1A2FF44C2900A9F43F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 09E3FD182FEB19100073862C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 09192EF82FF44C2700A9F43F; + remoteInfo = "music playerWatch Watch App"; + }; + 09E3FD312FEB19120073862C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 09E3FD182FEB19100073862C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 09E3FD1F2FEB19100073862C; + remoteInfo = "music player"; + }; + 09E3FD3B2FEB19120073862C /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 09E3FD182FEB19100073862C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 09E3FD1F2FEB19100073862C; + remoteInfo = "music player"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 09192F1D2FF44C2900A9F43F /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 09192F1C2FF44C2900A9F43F /* music playerWatch Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 090E8B7E2FEBDC5F00B552A1 /* music-player-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "music-player-Info.plist"; sourceTree = SOURCE_ROOT; }; + 09192EF92FF44C2700A9F43F /* music playerWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "music playerWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 09192EFB2FF44C2700A9F43F /* music_playerWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = music_playerWatchApp.swift; sourceTree = ""; }; + 09192EFD2FF44C2700A9F43F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 09192EFF2FF44C2900A9F43F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 09192F022FF44C2900A9F43F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 09192F082FF44C2900A9F43F /* music playerWatch Watch AppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "music playerWatch Watch AppTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 09192F0C2FF44C2900A9F43F /* music_playerWatch_Watch_AppTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = music_playerWatch_Watch_AppTests.swift; sourceTree = ""; }; + 09192F122FF44C2900A9F43F /* music playerWatch Watch AppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "music playerWatch Watch AppUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 09192F162FF44C2900A9F43F /* music_playerWatch_Watch_AppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = music_playerWatch_Watch_AppUITests.swift; sourceTree = ""; }; + 09192F182FF44C2900A9F43F /* music_playerWatch_Watch_AppUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = music_playerWatch_Watch_AppUITestsLaunchTests.swift; sourceTree = ""; }; + 09192F272FF44E6500A9F43F /* WatchConnectivityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchConnectivityManager.swift; sourceTree = ""; }; + 09192F282FF44E6500A9F43F /* NowPlayingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NowPlayingView.swift; sourceTree = ""; }; + 09192F2B2FF44EB100A9F43F /* PhoneConnectivityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhoneConnectivityManager.swift; sourceTree = ""; }; + 09D7E77E2FEFD0700070E7D6 /* music player.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "music player.entitlements"; sourceTree = ""; }; + 09E3FD202FEB19100073862C /* music player.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "music player.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 09E3FD232FEB19100073862C /* music_playerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = music_playerApp.swift; sourceTree = ""; }; + 09E3FD252FEB19100073862C /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 09E3FD272FEB19120073862C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 09E3FD2A2FEB19120073862C /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 09E3FD302FEB19120073862C /* music playerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "music playerTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 09E3FD342FEB19120073862C /* music_playerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = music_playerTests.swift; sourceTree = ""; }; + 09E3FD3A2FEB19120073862C /* music playerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "music playerUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 09E3FD3E2FEB19120073862C /* music_playerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = music_playerUITests.swift; sourceTree = ""; }; + 09E3FD402FEB19120073862C /* music_playerUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = music_playerUITestsLaunchTests.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 09192EF62FF44C2700A9F43F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09192F052FF44C2900A9F43F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09192F0F2FF44C2900A9F43F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09E3FD1D2FEB19100073862C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09E3FD2D2FEB19120073862C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09E3FD372FEB19120073862C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 09192EFA2FF44C2700A9F43F /* music playerWatch Watch App */ = { + isa = PBXGroup; + children = ( + 09192EFB2FF44C2700A9F43F /* music_playerWatchApp.swift */, + 09192EFD2FF44C2700A9F43F /* ContentView.swift */, + 09192F282FF44E6500A9F43F /* NowPlayingView.swift */, + 09192F272FF44E6500A9F43F /* WatchConnectivityManager.swift */, + 09192EFF2FF44C2900A9F43F /* Assets.xcassets */, + 09192F012FF44C2900A9F43F /* Preview Content */, + ); + path = "music playerWatch Watch App"; + sourceTree = ""; + }; + 09192F012FF44C2900A9F43F /* Preview Content */ = { + isa = PBXGroup; + children = ( + 09192F022FF44C2900A9F43F /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 09192F0B2FF44C2900A9F43F /* music playerWatch Watch AppTests */ = { + isa = PBXGroup; + children = ( + 09192F0C2FF44C2900A9F43F /* music_playerWatch_Watch_AppTests.swift */, + ); + path = "music playerWatch Watch AppTests"; + sourceTree = ""; + }; + 09192F152FF44C2900A9F43F /* music playerWatch Watch AppUITests */ = { + isa = PBXGroup; + children = ( + 09192F162FF44C2900A9F43F /* music_playerWatch_Watch_AppUITests.swift */, + 09192F182FF44C2900A9F43F /* music_playerWatch_Watch_AppUITestsLaunchTests.swift */, + ); + path = "music playerWatch Watch AppUITests"; + sourceTree = ""; + }; + 09E3FD172FEB19100073862C = { + isa = PBXGroup; + children = ( + 09E3FD222FEB19100073862C /* music player */, + 09E3FD332FEB19120073862C /* music playerTests */, + 09E3FD3D2FEB19120073862C /* music playerUITests */, + 09192EFA2FF44C2700A9F43F /* music playerWatch Watch App */, + 09192F0B2FF44C2900A9F43F /* music playerWatch Watch AppTests */, + 09192F152FF44C2900A9F43F /* music playerWatch Watch AppUITests */, + 09E3FD212FEB19100073862C /* Products */, + ); + sourceTree = ""; + }; + 09E3FD212FEB19100073862C /* Products */ = { + isa = PBXGroup; + children = ( + 09E3FD202FEB19100073862C /* music player.app */, + 09E3FD302FEB19120073862C /* music playerTests.xctest */, + 09E3FD3A2FEB19120073862C /* music playerUITests.xctest */, + 09192EF92FF44C2700A9F43F /* music playerWatch Watch App.app */, + 09192F082FF44C2900A9F43F /* music playerWatch Watch AppTests.xctest */, + 09192F122FF44C2900A9F43F /* music playerWatch Watch AppUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 09E3FD222FEB19100073862C /* music player */ = { + isa = PBXGroup; + children = ( + 09D7E77E2FEFD0700070E7D6 /* music player.entitlements */, + 090E8B7E2FEBDC5F00B552A1 /* music-player-Info.plist */, + 09E3FD232FEB19100073862C /* music_playerApp.swift */, + 09E3FD252FEB19100073862C /* ContentView.swift */, + 09192F2B2FF44EB100A9F43F /* PhoneConnectivityManager.swift */, + 09E3FD272FEB19120073862C /* Assets.xcassets */, + 09E3FD292FEB19120073862C /* Preview Content */, + ); + path = "music player"; + sourceTree = ""; + }; + 09E3FD292FEB19120073862C /* Preview Content */ = { + isa = PBXGroup; + children = ( + 09E3FD2A2FEB19120073862C /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 09E3FD332FEB19120073862C /* music playerTests */ = { + isa = PBXGroup; + children = ( + 09E3FD342FEB19120073862C /* music_playerTests.swift */, + ); + path = "music playerTests"; + sourceTree = ""; + }; + 09E3FD3D2FEB19120073862C /* music playerUITests */ = { + isa = PBXGroup; + children = ( + 09E3FD3E2FEB19120073862C /* music_playerUITests.swift */, + 09E3FD402FEB19120073862C /* music_playerUITestsLaunchTests.swift */, + ); + path = "music playerUITests"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 09192EF82FF44C2700A9F43F /* music playerWatch Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 09192F242FF44C2900A9F43F /* Build configuration list for PBXNativeTarget "music playerWatch Watch App" */; + buildPhases = ( + 09192EF52FF44C2700A9F43F /* Sources */, + 09192EF62FF44C2700A9F43F /* Frameworks */, + 09192EF72FF44C2700A9F43F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "music playerWatch Watch App"; + productName = "music playerWatch Watch App"; + productReference = 09192EF92FF44C2700A9F43F /* music playerWatch Watch App.app */; + productType = "com.apple.product-type.application"; + }; + 09192F072FF44C2900A9F43F /* music playerWatch Watch AppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 09192F252FF44C2900A9F43F /* Build configuration list for PBXNativeTarget "music playerWatch Watch AppTests" */; + buildPhases = ( + 09192F042FF44C2900A9F43F /* Sources */, + 09192F052FF44C2900A9F43F /* Frameworks */, + 09192F062FF44C2900A9F43F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 09192F0A2FF44C2900A9F43F /* PBXTargetDependency */, + ); + name = "music playerWatch Watch AppTests"; + productName = "music playerWatch Watch AppTests"; + productReference = 09192F082FF44C2900A9F43F /* music playerWatch Watch AppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 09192F112FF44C2900A9F43F /* music playerWatch Watch AppUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 09192F262FF44C2900A9F43F /* Build configuration list for PBXNativeTarget "music playerWatch Watch AppUITests" */; + buildPhases = ( + 09192F0E2FF44C2900A9F43F /* Sources */, + 09192F0F2FF44C2900A9F43F /* Frameworks */, + 09192F102FF44C2900A9F43F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 09192F142FF44C2900A9F43F /* PBXTargetDependency */, + ); + name = "music playerWatch Watch AppUITests"; + productName = "music playerWatch Watch AppUITests"; + productReference = 09192F122FF44C2900A9F43F /* music playerWatch Watch AppUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + 09E3FD1F2FEB19100073862C /* music player */ = { + isa = PBXNativeTarget; + buildConfigurationList = 09E3FD442FEB19120073862C /* Build configuration list for PBXNativeTarget "music player" */; + buildPhases = ( + 09E3FD1C2FEB19100073862C /* Sources */, + 09E3FD1D2FEB19100073862C /* Frameworks */, + 09E3FD1E2FEB19100073862C /* Resources */, + 09192F1D2FF44C2900A9F43F /* Embed Watch Content */, + ); + buildRules = ( + ); + dependencies = ( + 09192F1B2FF44C2900A9F43F /* PBXTargetDependency */, + ); + name = "music player"; + productName = "music player"; + productReference = 09E3FD202FEB19100073862C /* music player.app */; + productType = "com.apple.product-type.application"; + }; + 09E3FD2F2FEB19120073862C /* music playerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 09E3FD472FEB19120073862C /* Build configuration list for PBXNativeTarget "music playerTests" */; + buildPhases = ( + 09E3FD2C2FEB19120073862C /* Sources */, + 09E3FD2D2FEB19120073862C /* Frameworks */, + 09E3FD2E2FEB19120073862C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 09E3FD322FEB19120073862C /* PBXTargetDependency */, + ); + name = "music playerTests"; + productName = "music playerTests"; + productReference = 09E3FD302FEB19120073862C /* music playerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 09E3FD392FEB19120073862C /* music playerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 09E3FD4A2FEB19120073862C /* Build configuration list for PBXNativeTarget "music playerUITests" */; + buildPhases = ( + 09E3FD362FEB19120073862C /* Sources */, + 09E3FD372FEB19120073862C /* Frameworks */, + 09E3FD382FEB19120073862C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 09E3FD3C2FEB19120073862C /* PBXTargetDependency */, + ); + name = "music playerUITests"; + productName = "music playerUITests"; + productReference = 09E3FD3A2FEB19120073862C /* music playerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 09E3FD182FEB19100073862C /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 1540; + TargetAttributes = { + 09192EF82FF44C2700A9F43F = { + CreatedOnToolsVersion = 15.4; + }; + 09192F072FF44C2900A9F43F = { + CreatedOnToolsVersion = 15.4; + TestTargetID = 09192EF82FF44C2700A9F43F; + }; + 09192F112FF44C2900A9F43F = { + CreatedOnToolsVersion = 15.4; + TestTargetID = 09192EF82FF44C2700A9F43F; + }; + 09E3FD1F2FEB19100073862C = { + CreatedOnToolsVersion = 15.4; + }; + 09E3FD2F2FEB19120073862C = { + CreatedOnToolsVersion = 15.4; + TestTargetID = 09E3FD1F2FEB19100073862C; + }; + 09E3FD392FEB19120073862C = { + CreatedOnToolsVersion = 15.4; + TestTargetID = 09E3FD1F2FEB19100073862C; + }; + }; + }; + buildConfigurationList = 09E3FD1B2FEB19100073862C /* Build configuration list for PBXProject "music player" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 09E3FD172FEB19100073862C; + productRefGroup = 09E3FD212FEB19100073862C /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 09E3FD1F2FEB19100073862C /* music player */, + 09E3FD2F2FEB19120073862C /* music playerTests */, + 09E3FD392FEB19120073862C /* music playerUITests */, + 09192EF82FF44C2700A9F43F /* music playerWatch Watch App */, + 09192F072FF44C2900A9F43F /* music playerWatch Watch AppTests */, + 09192F112FF44C2900A9F43F /* music playerWatch Watch AppUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 09192EF72FF44C2700A9F43F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 09192F032FF44C2900A9F43F /* Preview Assets.xcassets in Resources */, + 09192F002FF44C2900A9F43F /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09192F062FF44C2900A9F43F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09192F102FF44C2900A9F43F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09E3FD1E2FEB19100073862C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 09E3FD2B2FEB19120073862C /* Preview Assets.xcassets in Resources */, + 09E3FD282FEB19120073862C /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09E3FD2E2FEB19120073862C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09E3FD382FEB19120073862C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 09192EF52FF44C2700A9F43F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 09192EFE2FF44C2700A9F43F /* ContentView.swift in Sources */, + 09192F292FF44E6500A9F43F /* WatchConnectivityManager.swift in Sources */, + 09192F2A2FF44E6500A9F43F /* NowPlayingView.swift in Sources */, + 09192EFC2FF44C2700A9F43F /* music_playerWatchApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09192F042FF44C2900A9F43F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 09192F0D2FF44C2900A9F43F /* music_playerWatch_Watch_AppTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09192F0E2FF44C2900A9F43F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 09192F172FF44C2900A9F43F /* music_playerWatch_Watch_AppUITests.swift in Sources */, + 09192F192FF44C2900A9F43F /* music_playerWatch_Watch_AppUITestsLaunchTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09E3FD1C2FEB19100073862C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 09192F2C2FF44EB100A9F43F /* PhoneConnectivityManager.swift in Sources */, + 09E3FD262FEB19100073862C /* ContentView.swift in Sources */, + 09E3FD242FEB19100073862C /* music_playerApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09E3FD2C2FEB19120073862C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 09E3FD352FEB19120073862C /* music_playerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 09E3FD362FEB19120073862C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 09E3FD3F2FEB19120073862C /* music_playerUITests.swift in Sources */, + 09E3FD412FEB19120073862C /* music_playerUITestsLaunchTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 09192F0A2FF44C2900A9F43F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 09192EF82FF44C2700A9F43F /* music playerWatch Watch App */; + targetProxy = 09192F092FF44C2900A9F43F /* PBXContainerItemProxy */; + }; + 09192F142FF44C2900A9F43F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 09192EF82FF44C2700A9F43F /* music playerWatch Watch App */; + targetProxy = 09192F132FF44C2900A9F43F /* PBXContainerItemProxy */; + }; + 09192F1B2FF44C2900A9F43F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 09192EF82FF44C2700A9F43F /* music playerWatch Watch App */; + targetProxy = 09192F1A2FF44C2900A9F43F /* PBXContainerItemProxy */; + }; + 09E3FD322FEB19120073862C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 09E3FD1F2FEB19100073862C /* music player */; + targetProxy = 09E3FD312FEB19120073862C /* PBXContainerItemProxy */; + }; + 09E3FD3C2FEB19120073862C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 09E3FD1F2FEB19100073862C /* music player */; + targetProxy = 09E3FD3B2FEB19120073862C /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 09192F1E2FF44C2900A9F43F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_ASSET_PATHS = "\"music playerWatch Watch App/Preview Content\""; + DEVELOPMENT_TEAM = K7BNMVXY4T; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "music playerWatch"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "aswer.music-player"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 4.0; + PRODUCT_BUNDLE_IDENTIFIER = "aswer.music-player.watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 8.0; + }; + name = Debug; + }; + 09192F1F2FF44C2900A9F43F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_ASSET_PATHS = "\"music playerWatch Watch App/Preview Content\""; + DEVELOPMENT_TEAM = K7BNMVXY4T; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "music playerWatch"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = "aswer.music-player"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 4.0; + PRODUCT_BUNDLE_IDENTIFIER = "aswer.music-player.watchkitapp"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 8.0; + }; + name = Release; + }; + 09192F202FF44C2900A9F43F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = K7BNMVXY4T; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "aswer.music-playerWatch-Watch-AppTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/music playerWatch Watch App.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/music playerWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 10.5; + }; + name = Debug; + }; + 09192F212FF44C2900A9F43F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = K7BNMVXY4T; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "aswer.music-playerWatch-Watch-AppTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/music playerWatch Watch App.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/music playerWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 10.5; + }; + name = Release; + }; + 09192F222FF44C2900A9F43F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = K7BNMVXY4T; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "aswer.music-playerWatch-Watch-AppUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = "music playerWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 10.5; + }; + name = Debug; + }; + 09192F232FF44C2900A9F43F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = K7BNMVXY4T; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "aswer.music-playerWatch-Watch-AppUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = "music playerWatch Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 10.5; + }; + name = Release; + }; + 09E3FD422FEB19120073862C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + 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; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 09E3FD432FEB19120073862C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + 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; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 09E3FD452FEB19120073862C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "music player/music player.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_ASSET_PATHS = "\"music player/Preview Content\""; + DEVELOPMENT_TEAM = K7BNMVXY4T; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "music-player-Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 4.0; + PRODUCT_BUNDLE_IDENTIFIER = "aswer.music-player"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 09E3FD462FEB19120073862C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "music player/music player.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_ASSET_PATHS = "\"music player/Preview Content\""; + DEVELOPMENT_TEAM = K7BNMVXY4T; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "music-player-Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 4.0; + PRODUCT_BUNDLE_IDENTIFIER = "aswer.music-player"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 09E3FD482FEB19120073862C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = K7BNMVXY4T; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "aswer.music-playerTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/music player.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/music player"; + }; + name = Debug; + }; + 09E3FD492FEB19120073862C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = K7BNMVXY4T; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "aswer.music-playerTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/music player.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/music player"; + }; + name = Release; + }; + 09E3FD4B2FEB19120073862C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = K7BNMVXY4T; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "aswer.music-playerUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = "music player"; + }; + name = Debug; + }; + 09E3FD4C2FEB19120073862C /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = K7BNMVXY4T; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "aswer.music-playerUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = "music player"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 09192F242FF44C2900A9F43F /* Build configuration list for PBXNativeTarget "music playerWatch Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 09192F1E2FF44C2900A9F43F /* Debug */, + 09192F1F2FF44C2900A9F43F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 09192F252FF44C2900A9F43F /* Build configuration list for PBXNativeTarget "music playerWatch Watch AppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 09192F202FF44C2900A9F43F /* Debug */, + 09192F212FF44C2900A9F43F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 09192F262FF44C2900A9F43F /* Build configuration list for PBXNativeTarget "music playerWatch Watch AppUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 09192F222FF44C2900A9F43F /* Debug */, + 09192F232FF44C2900A9F43F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 09E3FD1B2FEB19100073862C /* Build configuration list for PBXProject "music player" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 09E3FD422FEB19120073862C /* Debug */, + 09E3FD432FEB19120073862C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 09E3FD442FEB19120073862C /* Build configuration list for PBXNativeTarget "music player" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 09E3FD452FEB19120073862C /* Debug */, + 09E3FD462FEB19120073862C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 09E3FD472FEB19120073862C /* Build configuration list for PBXNativeTarget "music playerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 09E3FD482FEB19120073862C /* Debug */, + 09E3FD492FEB19120073862C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 09E3FD4A2FEB19120073862C /* Build configuration list for PBXNativeTarget "music playerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 09E3FD4B2FEB19120073862C /* Debug */, + 09E3FD4C2FEB19120073862C /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 09E3FD182FEB19100073862C /* Project object */; +} diff --git a/music player.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/music player.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/music player.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/music player.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/music player.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/music player.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/music player.xcodeproj/project.xcworkspace/xcuserdata/aswer.xcuserdatad/UserInterfaceState.xcuserstate b/music player.xcodeproj/project.xcworkspace/xcuserdata/aswer.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..8b949cb Binary files /dev/null and b/music player.xcodeproj/project.xcworkspace/xcuserdata/aswer.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/music player.xcodeproj/xcuserdata/aswer.xcuserdatad/xcschemes/xcschememanagement.plist b/music player.xcodeproj/xcuserdata/aswer.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..e20ddad --- /dev/null +++ b/music player.xcodeproj/xcuserdata/aswer.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,19 @@ + + + + + SchemeUserState + + music player.xcscheme_^#shared#^_ + + orderHint + 1 + + music playerWatch Watch App.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/music player/.DS_Store b/music player/.DS_Store new file mode 100644 index 0000000..ca315e8 Binary files /dev/null and b/music player/.DS_Store differ diff --git a/music player/Assets.xcassets/AccentColor.colorset/Contents.json b/music player/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/music player/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/music player/Assets.xcassets/AppIcon.appiconset/AppIcon 1.jpg b/music player/Assets.xcassets/AppIcon.appiconset/AppIcon 1.jpg new file mode 100644 index 0000000..a0a310d Binary files /dev/null and b/music player/Assets.xcassets/AppIcon.appiconset/AppIcon 1.jpg differ diff --git a/music player/Assets.xcassets/AppIcon.appiconset/Contents.json b/music player/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ff1c1ff --- /dev/null +++ b/music player/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon 1.jpg", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/music player/Assets.xcassets/Contents.json b/music player/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/music player/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/music player/ContentView.swift b/music player/ContentView.swift new file mode 100644 index 0000000..d3d564f --- /dev/null +++ b/music player/ContentView.swift @@ -0,0 +1,2969 @@ +import SwiftUI +import AVFoundation +import MediaPlayer +import Combine +import UniformTypeIdentifiers +import AVKit +import PhotosUI + +// MARK: - Модель трека +struct Track: Identifiable, Hashable { + let id = UUID() + let url: URL + let title: String + let artist: String + + func hash(into hasher: inout Hasher) { + hasher.combine(url) + } +} + +// MARK: - Модель строки текста +struct LyricLine: Identifiable, Codable { + var id = UUID() + var time: TimeInterval + var text: String +} + +// MARK: - Модель ответа от LRCLIB +struct LRCLibSearchResult: Codable { + let id: Int? + let trackName: String? + let artistName: String? + let syncedLyrics: String? + let plainLyrics: String? +} + +// MARK: - Перечисления +enum RepeatMode: Int, CaseIterable { + case none = 0 + case all = 1 + case one = 2 + + var iconName: String { + switch self { + case .none: return "repeat" + case .all: return "repeat" + case .one: return "repeat.1" + } + } +} + +enum AppTheme: Int, CaseIterable { + case system = 0 + case light = 1 + case dark = 2 + + var colorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } + + var title: String { + switch self { + case .system: return "Системная" + case .light: return "Светлая" + case .dark: return "Темная" + } + } +} + +// MARK: - Гибридный плеер с эквалайзером и автоматическим фолбеком +class AVAudioPlayerWithEQ: NSObject, AVAudioPlayerDelegate { + private var engine: AVAudioEngine? + private var playerNode: AVAudioPlayerNode? + private var eqNode: AVAudioUnitEQ? + private var audioFile: AVAudioFile? + + private var fallbackPlayer: AVAudioPlayer? + private var useFallback: Bool = false + + var url: URL? + var onFinishPlaying: (() -> Void)? + + private var seekTime: TimeInterval = 0 + private var pausedTime: TimeInterval = 0 + private var totalDuration: TimeInterval = 0 + private var sampleRate: Double = 44100 + private var totalFrames: AVAudioFramePosition = 0 + private var isSeeking = false + + var volume: Float = 1.0 { + didSet { + if useFallback { + fallbackPlayer?.volume = volume + } else { + playerNode?.volume = volume + } + } + } + + var isPlaying: Bool { + if useFallback { + return fallbackPlayer?.isPlaying ?? false + } else { + return playerNode?.isPlaying ?? false + } + } + + var currentTime: TimeInterval { + get { + if useFallback { + return fallbackPlayer?.currentTime ?? 0 + } else { + guard let playerNode = playerNode else { return pausedTime } + if let nodeTime = playerNode.lastRenderTime, + let playerTime = playerNode.playerTime(forNodeTime: nodeTime) { + let currentSecs = Double(playerTime.sampleTime) / playerTime.sampleRate + let computed = min(seekTime + currentSecs, totalDuration) + pausedTime = computed + return computed + } + return pausedTime + } + } + set { + seek(to: newValue) + } + } + + var duration: TimeInterval { + if useFallback { + return fallbackPlayer?.duration ?? 0 + } else { + return totalDuration + } + } + + init(contentsOf url: URL) throws { + self.url = url + super.init() + + do { + self.engine = AVAudioEngine() + self.playerNode = AVAudioPlayerNode() + self.eqNode = AVAudioUnitEQ(numberOfBands: 10) + + let file = try AVAudioFile(forReading: url) + self.audioFile = file + self.totalDuration = Double(file.length) / file.fileFormat.sampleRate + self.sampleRate = file.fileFormat.sampleRate + self.totalFrames = file.length + + guard let engine = engine, let playerNode = playerNode, let eqNode = eqNode else { + throw NSError(domain: "AVAudioPlayerWithEQ", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to create nodes"]) + } + + engine.attach(playerNode) + engine.attach(eqNode) + + let frequencies: [Float] = [32, 64, 125, 250, 500, 1000, 2000, 4000, 8000, 16000] + let isEqEnabled = UserDefaults.standard.bool(forKey: "eq_enabled") + + for i in 0..<10 { + let band = eqNode.bands[i] + band.frequency = frequencies[i] + band.filterType = .parametric + band.bypass = !isEqEnabled + let savedGain = Float(UserDefaults.standard.double(forKey: "eq_band_\(i)")) + band.gain = savedGain + } + + let format = file.processingFormat + engine.connect(playerNode, to: eqNode, format: format) + engine.connect(eqNode, to: engine.mainMixerNode, format: format) + + try engine.start() + self.useFallback = false + scheduleFile() + } catch { + print("AVAudioEngine setup failed, falling back to AVAudioPlayer: \(error)") + self.useFallback = true + self.fallbackPlayer = try AVAudioPlayer(contentsOf: url) + self.fallbackPlayer?.delegate = self + self.fallbackPlayer?.prepareToPlay() + } + } + + func play() { + if useFallback { + fallbackPlayer?.play() + } else { + if let engine = engine, !engine.isRunning { + try? engine.start() + } + playerNode?.play() + } + } + + func pause() { + if useFallback { + fallbackPlayer?.pause() + } else { + pausedTime = currentTime + playerNode?.pause() + } + } + + func stop() { + if useFallback { + fallbackPlayer?.stop() + } else { + playerNode?.stop() + engine?.stop() + } + } + + func seek(to time: TimeInterval) { + if useFallback { + fallbackPlayer?.currentTime = time + } else { + guard let playerNode = playerNode, let file = audioFile else { return } + let wasPlaying = playerNode.isPlaying + isSeeking = true + playerNode.stop() + + seekTime = max(0, min(time, totalDuration)) + pausedTime = seekTime + let startFrame = AVAudioFramePosition(seekTime * sampleRate) + let framesToPlay = AVAudioFrameCount(totalFrames - startFrame) + + if framesToPlay > 100 { + if #available(iOS 15.0, *) { + playerNode.scheduleSegment(file, startingFrame: startFrame, frameCount: framesToPlay, at: nil, completionCallbackType: .dataPlayedBack) { [weak self] _ in + guard let self = self, !self.isSeeking else { return } + DispatchQueue.main.async { self.onFinishPlaying?() } + } + } else { + playerNode.scheduleSegment(file, startingFrame: startFrame, frameCount: framesToPlay, at: nil) { [weak self] in + guard let self = self, !self.isSeeking else { return } + DispatchQueue.main.async { self.onFinishPlaying?() } + } + } + } + + isSeeking = false + if wasPlaying { + playerNode.play() + } + } + } + + private func scheduleFile() { + guard let playerNode = playerNode, let file = audioFile else { return } + if #available(iOS 15.0, *) { + playerNode.scheduleFile(file, at: nil, completionCallbackType: .dataPlayedBack) { [weak self] _ in + guard let self = self, !self.isSeeking else { return } + DispatchQueue.main.async { self.onFinishPlaying?() } + } + } else { + playerNode.scheduleFile(file, at: nil) { [weak self] in + guard let self = self, !self.isSeeking else { return } + DispatchQueue.main.async { self.onFinishPlaying?() } + } + } + } + + func updateEQSettings() { + guard !useFallback, let eqNode = eqNode else { return } + let isEqEnabled = UserDefaults.standard.bool(forKey: "eq_enabled") + for i in 0..<10 { + let band = eqNode.bands[i] + band.bypass = !isEqEnabled + let savedGain = Float(UserDefaults.standard.double(forKey: "eq_band_\(i)")) + band.gain = savedGain + } + } + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + if flag { + self.onFinishPlaying?() + } + } +} + +struct EQPreset: Identifiable, Hashable { + var id: String { name } + let name: String + let gains: [Double] +} + +let eqPresets: [EQPreset] = [ + EQPreset(name: "Обычный (Flat)", gains: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]), + EQPreset(name: "Усиление баса", gains: [8.0, 6.5, 5.0, 3.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]), + EQPreset(name: "Усиление вокала", gains: [-3.0, -2.0, -1.0, 1.0, 3.0, 4.5, 4.0, 3.0, 1.5, -1.0]), + EQPreset(name: "Акустика", gains: [3.5, 3.0, 1.5, 2.0, 1.0, 1.5, 2.5, 2.0, 1.5, 1.0]), + EQPreset(name: "Классика", gains: [5.0, 4.0, 3.0, 2.0, -1.0, -1.0, 0.0, 2.0, 3.5, 4.5]), + EQPreset(name: "Электроника", gains: [5.0, 4.0, 2.0, 0.0, -2.0, 2.5, 1.5, 2.0, 4.0, 5.5]), + EQPreset(name: "Джаз", gains: [4.0, 3.0, 1.5, 2.0, -1.5, -1.5, 0.0, 1.5, 3.0, 4.0]), + EQPreset(name: "Поп", gains: [-2.0, -1.5, 0.0, 2.5, 4.5, 4.0, 2.5, 0.0, -1.5, -2.0]), + EQPreset(name: "Рок", gains: [5.0, 4.0, -2.0, -4.0, -1.5, 1.5, 3.5, 4.5, 5.0, 5.0]) +] + +class LyricsManager: ObservableObject { + static let shared = LyricsManager() + private let storageKey = "trackLyrics_v1" + @Published private var store: [String: [LyricLine]] = [:] + + init() { load() } + + func lyrics(for url: URL) -> [LyricLine] { store[url.lastPathComponent] ?? [] } + func hasLyrics(for url: URL) -> Bool { !(store[url.lastPathComponent]?.isEmpty ?? true) } + + func setLyrics(_ lines: [LyricLine], for url: URL) { + store[url.lastPathComponent] = lines + save() + objectWillChange.send() + } + + func deleteLyrics(for url: URL) { + store.removeValue(forKey: url.lastPathComponent) + save() + } + + func parseLRC(_ content: String) -> [LyricLine] { + var lines: [LyricLine] = [] + let timeRx = try! NSRegularExpression(pattern: #"\[(\d{2}):(\d{2})[\.:](\d{2,3})\]"#) + let metaRx = try! NSRegularExpression(pattern: #"^\[[a-zA-Z]+:.*\]$"#) + + for raw in content.components(separatedBy: "\n") { + let t = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if t.isEmpty { continue } + let nsT = t as NSString + let r = NSRange(t.startIndex..., in: t) + if metaRx.firstMatch(in: t, range: r) != nil { continue } + let matches = timeRx.matches(in: t, range: r) + if matches.isEmpty { + if !t.hasPrefix("[") { lines.append(LyricLine(time: -1, text: t)) } + continue + } + let lastEnd = matches.last!.range.location + matches.last!.range.length + let text = nsT.substring(from: lastEnd).trimmingCharacters(in: .whitespaces) + guard !text.isEmpty else { continue } + for m in matches { + let mm = Double(nsT.substring(with: m.range(at: 1))) ?? 0 + let ss = Double(nsT.substring(with: m.range(at: 2))) ?? 0 + let ms = Double(nsT.substring(with: m.range(at: 3))) ?? 0 + let div = nsT.substring(with: m.range(at: 3)).count == 3 ? 1000.0 : 100.0 + lines.append(LyricLine(time: mm * 60 + ss + ms / div, text: text)) + } + } + return lines.sorted { + let a = $0.time < 0 ? Double.infinity : $0.time + let b = $1.time < 0 ? Double.infinity : $1.time + return a < b + } + } + + func lrcText(for lines: [LyricLine]) -> String { + lines.map { l in + guard l.time >= 0 else { return l.text } + let m = Int(l.time) / 60 + let s = Int(l.time) % 60 + let ms = Int((l.time - Double(Int(l.time))) * 100) + return String(format: "[%02d:%02d.%02d]%@", m, s, ms, l.text) + }.joined(separator: "\n") + } + + private func save() { + if let data = try? JSONEncoder().encode(store) { + UserDefaults.standard.set(data, forKey: storageKey) + } + } + + private func load() { + guard let d = UserDefaults.standard.data(forKey: storageKey), + let dict = try? JSONDecoder().decode([String: [LyricLine]].self, from: d) + else { return } + store = dict + } + + func fetchLyricsFromLRCLib(title: String, artist: String) async -> String? { + var cleanTitle = title + for ext in [".mp3", ".m4a", ".wav", ".flac", ".aac"] { + cleanTitle = cleanTitle.replacingOccurrences(of: ext, with: "", options: .caseInsensitive) + } + let cleanArtist = artist == "Локальный файл" ? "" : artist + + var components = URLComponents(string: "https://lrclib.net/api/search")! + components.queryItems = [ + URLQueryItem(name: "track_name", value: cleanTitle), + URLQueryItem(name: "artist_name", value: cleanArtist) + ].filter { !($0.value?.trimmingCharacters(in: .whitespaces).isEmpty ?? true) } + + guard let url = components.url else { return nil } + + var request = URLRequest(url: url) + request.setValue("SwiftUIMusicPlayer/1.0", forHTTPHeaderField: "User-Agent") + + do { + let (data, _) = try await URLSession.shared.data(for: request) + let results = try JSONDecoder().decode([LRCLibSearchResult].self, from: data) + + if let bestMatch = results.first { + return bestMatch.syncedLyrics ?? bestMatch.plainLyrics + } + } catch { + print("LRCLib Network Error: \(error)") + } + return nil + } +} + +class PlaybackTime: ObservableObject { + static let shared = PlaybackTime() + @Published var currentTime: TimeInterval = 0 +} + +// MARK: - Модель и менеджер пользовательских метаданных трека +struct TrackMetadataOverride: Codable { + var title: String + var artist: String +} + +class TrackMetadataManager: ObservableObject { + static let shared = TrackMetadataManager() + private let storageKey = "trackMetadataOverrides_v1" + @Published private var store: [String: TrackMetadataOverride] = [:] + + init() { load() } + + func override(for url: URL) -> TrackMetadataOverride? { + store[url.lastPathComponent] + } + + func hasOverride(for url: URL) -> Bool { + store[url.lastPathComponent] != nil + } + + func setMetadata(title: String, artist: String, for url: URL) { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedArtist = artist.trimmingCharacters(in: .whitespacesAndNewlines) + store[url.lastPathComponent] = TrackMetadataOverride( + title: trimmedTitle.isEmpty ? url.lastPathComponent : trimmedTitle, + artist: trimmedArtist.isEmpty ? "Локальный файл" : trimmedArtist + ) + save() + objectWillChange.send() + } + + func resetMetadata(for url: URL) { + store.removeValue(forKey: url.lastPathComponent) + save() + objectWillChange.send() + } + + private func save() { + if let data = try? JSONEncoder().encode(store) { + UserDefaults.standard.set(data, forKey: storageKey) + } + } + + private func load() { + guard let d = UserDefaults.standard.data(forKey: storageKey), + let dict = try? JSONDecoder().decode([String: TrackMetadataOverride].self, from: d) + else { return } + store = dict + } +} + +// MARK: - Менеджер пользовательских обложек треков +class ArtworkManager: ObservableObject { + static let shared = ArtworkManager() + private var cache: [String: UIImage] = [:] + + private var artworkDirectory: URL { + let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let dir = docs.appendingPathComponent("CustomArtwork", isDirectory: true) + if !FileManager.default.fileExists(atPath: dir.path) { + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } + return dir + } + + private func fileURL(for trackURL: URL) -> URL { + artworkDirectory.appendingPathComponent(trackURL.lastPathComponent + ".jpg") + } + + func hasCustomArtwork(for trackURL: URL) -> Bool { + FileManager.default.fileExists(atPath: fileURL(for: trackURL).path) + } + + func artwork(for trackURL: URL) -> UIImage? { + let key = trackURL.lastPathComponent + if let cached = cache[key] { return cached } + guard let data = try? Data(contentsOf: fileURL(for: trackURL)), + let image = UIImage(data: data) + else { return nil } + cache[key] = image + return image + } + + func setArtwork(_ image: UIImage, for trackURL: URL) { + guard let data = image.jpegData(compressionQuality: 0.85) else { return } + try? data.write(to: fileURL(for: trackURL), options: .atomic) + cache[trackURL.lastPathComponent] = image + objectWillChange.send() + } + + func removeArtwork(for trackURL: URL) { + try? FileManager.default.removeItem(at: fileURL(for: trackURL)) + cache.removeValue(forKey: trackURL.lastPathComponent) + objectWillChange.send() + } +} + +class MusicManager: NSObject, ObservableObject { + static let shared = MusicManager() + + @Published var audioPlayer: AVAudioPlayerWithEQ? + + @Published var tracks: [Track] = [] + private var shuffledTracks: [Track] = [] + + @Published var isPlaying = false + @Published var currentTrackIndex = 0 + + @Published var volume: Float = 0.7 { + didSet { applyVolumeChange() } + } + + @Published var shuffleOn = false { didSet { updateShuffledList() } } + @Published var repeatMode: RepeatMode = .none + + @Published var currentArtwork: UIImage? + @Published var gradientColors: [Color] = [Color(.systemGray5), Color(.systemGray3)] + @Published var trackDuration: TimeInterval = 0 + + var currentTime: TimeInterval { + get { PlaybackTime.shared.currentTime } + set { PlaybackTime.shared.currentTime = newValue } + } + + @Published var currentTitle: String = "" + @Published var currentArtist: String = "" + + private var timer: AnyCancellable? + private var cancellables = Set() + private let systemVolumeView = MPVolumeView(frame: .zero) + + var currentList: [Track] { + return shuffleOn ? shuffledTracks : tracks + } + + var currentTrack: Track? { + guard !currentList.isEmpty && currentTrackIndex < currentList.count else { return nil } + return currentList[currentTrackIndex] + } + + override init() { + super.init() + setupSession() + setupRemoteCommands() + loadTracksFromDisk() + setupVolumeObservation() + setupInterruptionObserving() + + DispatchQueue.main.async { + self.syncVolumeWithCurrentMode() + } + } + + func setupSession() { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, policy: .longFormAudio) + try AVAudioSession.sharedInstance().setActive(true) + } catch { print("Session error: \(error)") } + } + + func loadTracksFromDisk() { + let fileManager = FileManager.default + guard let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + do { + let content = try fileManager.contentsOfDirectory(at: docs, includingPropertiesForKeys: nil) + let audioFiles = content.filter { ["mp3", "m4a", "wav"].contains($0.pathExtension.lowercased()) } + + var loadedTracks: [Track] = [] + + for url in audioFiles { + let asset = AVAsset(url: url) + var title = url.lastPathComponent + var artist = "Локальный файл" + + for item in asset.metadata { + guard let key = item.commonKey else { continue } + if key == .commonKeyTitle { title = item.stringValue ?? title } + if key == .commonKeyArtist { artist = item.stringValue ?? artist } + } + + if let override = TrackMetadataManager.shared.override(for: url) { + title = override.title + artist = override.artist + } + + loadedTracks.append(Track(url: url, title: title, artist: artist)) + } + + DispatchQueue.main.async { + self.tracks = loadedTracks.sorted(by: { $0.title < $1.title }) + self.updateShuffledList() + } + } catch { print("Disk error: \(error)") } + } + + func originalMetadata(for url: URL) -> (title: String, artist: String) { + let asset = AVAsset(url: url) + var title = url.lastPathComponent + var artist = "Локальный файл" + for item in asset.metadata { + guard let key = item.commonKey else { continue } + if key == .commonKeyTitle { title = item.stringValue ?? title } + if key == .commonKeyArtist { artist = item.stringValue ?? artist } + } + return (title, artist) + } + + func updateMetadata(title: String, artist: String, for url: URL) { + TrackMetadataManager.shared.setMetadata(title: title, artist: artist, for: url) + loadTracksFromDisk() + + if audioPlayer?.url == url { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedArtist = artist.trimmingCharacters(in: .whitespacesAndNewlines) + currentTitle = trimmedTitle.isEmpty ? url.lastPathComponent : trimmedTitle + currentArtist = trimmedArtist.isEmpty ? "Локальный файл" : trimmedArtist + updateNowPlaying() + } + } + + func resetMetadata(for url: URL) { + TrackMetadataManager.shared.resetMetadata(for: url) + loadTracksFromDisk() + + if audioPlayer?.url == url { + let original = originalMetadata(for: url) + currentTitle = original.title + currentArtist = original.artist + updateNowPlaying() + } + } + + func updateArtwork(_ image: UIImage, for url: URL) { + ArtworkManager.shared.setArtwork(image, for: url) + if audioPlayer?.url == url { + currentArtwork = image + gradientColors = image.extractGradientColors() + updateNowPlaying() + } + } + + func removeCustomArtwork(for url: URL) { + ArtworkManager.shared.removeArtwork(for: url) + if audioPlayer?.url == url { + fetchArtwork(for: url) + updateNowPlaying() + } + } + + func importTracks(from urls: [URL]) { + let fileManager = FileManager.default + guard let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + + var lrcURLs: [URL] = [] + var audioURLs: [URL] = [] + + for url in urls { + if url.pathExtension.lowercased() == "lrc" { lrcURLs.append(url) } + else { audioURLs.append(url) } + } + + for url in audioURLs { + let accessing = url.startAccessingSecurityScopedResource() + let dest = docs.appendingPathComponent(url.lastPathComponent) + try? fileManager.copyItem(at: url, to: dest) + if accessing { url.stopAccessingSecurityScopedResource() } + } + + for lrcURL in lrcURLs { + let accessing = lrcURL.startAccessingSecurityScopedResource() + defer { if accessing { lrcURL.stopAccessingSecurityScopedResource() } } + guard let content = try? String(contentsOf: lrcURL, encoding: .utf8) else { continue } + let baseName = lrcURL.deletingPathExtension().lastPathComponent + if let matchURL = findAudioURL(for: baseName, in: docs) { + let lines = LyricsManager.shared.parseLRC(content) + LyricsManager.shared.setLyrics(lines, for: matchURL) + } + } + + loadTracksFromDisk() + } + + private func findAudioURL(for baseName: String, in directory: URL) -> URL? { + for ext in ["mp3", "m4a", "wav", "flac", "aac"] { + let url = directory.appendingPathComponent(baseName + "." + ext) + if FileManager.default.fileExists(atPath: url.path) { return url } + } + return nil + } + + func deleteTrack(at offsets: IndexSet) { + offsets.forEach { try? FileManager.default.removeItem(at: tracks[$0].url) } + loadTracksFromDisk() + } + + func deleteTrack(track: Track) { + try? FileManager.default.removeItem(at: track.url) + loadTracksFromDisk() + } + + func deleteAllTracks() { + audioPlayer?.stop() + isPlaying = false + + for track in tracks { + try? FileManager.default.removeItem(at: track.url) + } + + DispatchQueue.main.async { + self.tracks.removeAll() + self.shuffledTracks.removeAll() + self.currentArtwork = nil + self.gradientColors = [Color(.systemGray5), Color(.systemGray4), Color(.systemGray3)] + self.currentTitle = "" + self.currentArtist = "" + self.currentTime = 0 + self.trackDuration = 0 + self.updateNowPlaying() + } + } + + private func updateShuffledList() { + shuffledTracks = tracks.shuffled() + if let playingURL = audioPlayer?.url, let newIndex = shuffledTracks.firstIndex(where: { $0.url == playingURL }) { + currentTrackIndex = newIndex + } + } + + func playTrack(at index: Int, in list: [Track]? = nil) { + let activeList = list ?? currentList + guard !activeList.isEmpty else { return } + let safeIndex = (index < 0) ? 0 : (index >= activeList.count ? 0 : index) + currentTrackIndex = safeIndex + + let track = activeList[currentTrackIndex] + do { + audioPlayer?.onFinishPlaying = nil // Clear old callback securely + audioPlayer?.stop() // Prevent ghostly double playback + + audioPlayer = try AVAudioPlayerWithEQ(contentsOf: track.url) + + audioPlayer?.onFinishPlaying = { [weak self] in + guard let self = self else { return } + if self.repeatMode == .one { + self.seek(to: 0) + self.audioPlayer?.play() + self.isPlaying = true + } + else if self.repeatMode == .all || self.currentTrackIndex < self.currentList.count - 1 { + self.nextTrack() + } + else { + self.isPlaying = false + self.updateNowPlaying() + } + } + + if !UserDefaults.standard.bool(forKey: "controlSystemVolume") { + audioPlayer?.volume = volume + } else { + audioPlayer?.volume = 1.0 + } + + audioPlayer?.play() + isPlaying = true + trackDuration = audioPlayer?.duration ?? 0 + + self.currentTitle = track.title + self.currentArtist = track.artist + fetchArtwork(for: track.url) + + startTimer() + updateNowPlaying() + } catch { print("Play error: \(error)") } + } + + func resume() { + guard audioPlayer?.isPlaying != true else { + updateNowPlaying() + return + } + if currentTime >= trackDuration - 0.5 && trackDuration > 0 { + seek(to: 0) + } + audioPlayer?.play() + isPlaying = true + startTimer() + updateNowPlaying() + } + + func pause() { + guard audioPlayer?.isPlaying == true else { + updateNowPlaying() + return + } + audioPlayer?.pause() + isPlaying = false + updateNowPlaying() + } + + func playPause() { + if audioPlayer?.isPlaying == true { + pause() + } else { + resume() + } + } + + func nextTrack() { + if currentList.isEmpty { return } + playTrack(at: (currentTrackIndex + 1) % currentList.count) + } + + func prevTrack() { + if currentList.isEmpty { return } + if (audioPlayer?.currentTime ?? 0) > 3.0 { seek(to: 0); return } + playTrack(at: (currentTrackIndex - 1 + currentList.count) % currentList.count) + } + + func seek(to time: TimeInterval) { + audioPlayer?.seek(to: time) + currentTime = time + updateNowPlaying() + } + + func toggleShuffle() { shuffleOn.toggle() } + func toggleRepeat() { repeatMode = RepeatMode(rawValue: (repeatMode.rawValue + 1) % RepeatMode.allCases.count) ?? .none } + + private func startTimer() { + timer?.cancel() + var tickCount = 0 + timer = Timer.publish(every: 0.05, on: .main, in: .common).autoconnect().sink { [weak self] _ in + guard let self = self else { return } + PlaybackTime.shared.currentTime = self.audioPlayer?.currentTime ?? 0 + + tickCount += 1 + if tickCount % 20 == 0 { + self.syncNowPlayingIfNeeded() + } + } + } + + private func syncNowPlayingIfNeeded() { + let actuallyPlaying = audioPlayer?.isPlaying ?? false + if actuallyPlaying != isPlaying { + isPlaying = actuallyPlaying + } + updateNowPlaying() + } + + private func fetchArtwork(for url: URL) { + self.currentArtwork = nil + + if let custom = ArtworkManager.shared.artwork(for: url) { + self.currentArtwork = custom + self.gradientColors = custom.extractGradientColors() + return + } + + let asset = AVAsset(url: url) + for item in asset.metadata where item.commonKey == .commonKeyArtwork { + if let data = item.dataValue, let img = UIImage(data: data) { + self.currentArtwork = img + self.gradientColors = img.extractGradientColors() + } + } + if self.currentArtwork == nil { + self.gradientColors = [Color(.systemGray5), Color(.systemGray4), Color(.systemGray3)] + } + } + + func updateNowPlaying() { + var info: [String: Any] = [ + MPMediaItemPropertyTitle: currentTitle, + MPMediaItemPropertyArtist: currentArtist, + MPNowPlayingInfoPropertyElapsedPlaybackTime: audioPlayer?.currentTime ?? 0, + MPMediaItemPropertyPlaybackDuration: trackDuration, + MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0 + ] + + if let image = currentArtwork { + info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in + return image + } + } + + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + MPNowPlayingInfoCenter.default().playbackState = isPlaying ? .playing : .paused + } + + func setupRemoteCommands() { + let center = MPRemoteCommandCenter.shared() + center.playCommand.addTarget { [weak self] _ in + self?.resume() + return .success + } + center.pauseCommand.addTarget { [weak self] _ in + self?.pause() + return .success + } + center.nextTrackCommand.addTarget { [weak self] _ in self?.nextTrack(); return .success } + center.previousTrackCommand.addTarget { [weak self] _ in self?.prevTrack(); return .success } + } + + private func setupInterruptionObserving() { + NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification) + .sink { [weak self] notification in + self?.handleInterruption(notification) + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: AVAudioSession.routeChangeNotification) + .sink { [weak self] _ in + self?.syncNowPlayingIfNeeded() + } + .store(in: &cancellables) + } + + private func handleInterruption(_ notification: Notification) { + guard let info = notification.userInfo, + let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { return } + + switch type { + case .began: + isPlaying = false + updateNowPlaying() + + case .ended: + var shouldResume = false + if let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt { + shouldResume = AVAudioSession.InterruptionOptions(rawValue: optionsValue).contains(.shouldResume) + } + + do { + try AVAudioSession.sharedInstance().setActive(true) + } catch { + print("Не удалось повторно активировать аудиосессию: \(error)") + } + + if shouldResume { + audioPlayer?.play() + isPlaying = true + } else { + isPlaying = false + } + updateNowPlaying() + + @unknown default: + break + } + } + + private func applyVolumeChange() { + let controlSystem = UserDefaults.standard.bool(forKey: "controlSystemVolume") + if controlSystem { + setSystemVolume(volume) + } else { + audioPlayer?.volume = volume + } + } + + func syncVolumeWithCurrentMode() { + let controlSystem = UserDefaults.standard.bool(forKey: "controlSystemVolume") + if controlSystem { + self.volume = AVAudioSession.sharedInstance().outputVolume + audioPlayer?.volume = 1.0 + } else { + if let player = audioPlayer { + self.volume = player.volume + } + } + } + + private func setSystemVolume(_ value: Float) { + let slider = systemVolumeView.subviews.first(where: { $0 is UISlider }) as? UISlider + DispatchQueue.main.async { + slider?.value = value + } + } + + private func setupVolumeObservation() { + NotificationCenter.default.publisher(for: NSNotification.Name("AVSystemController_SystemVolumeDidChangeNotification")) + .sink { [weak self] notification in + guard let self = self else { return } + if UserDefaults.standard.bool(forKey: "controlSystemVolume") { + if let volumeValue = notification.userInfo?["AVSystemController_AudioVolumeNotificationParameter"] as? Float { + DispatchQueue.main.async { + self.volume = volumeValue + } + } + } + } + .store(in: &cancellables) + } + + func applyEQChanges() { + audioPlayer?.updateEQSettings() + } +} + +// MARK: - Вертикальный ползунок эквалайзера в стиле аудио-консоли +struct VerticalSlider: View { + @Binding var value: Double + var range: ClosedRange = -12...12 + let label: String + + var body: some View { + VStack(spacing: 8) { + Text(String(format: "%+.1f", value)) + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundColor(.gray) + .frame(width: 48) + + GeometryReader { geo in + let height = geo.size.height + let pct = CGFloat((value - range.lowerBound) / (range.upperBound - range.lowerBound)) + + ZStack(alignment: .bottom) { + Capsule() + .fill(Color.primary.opacity(0.1)) + .frame(width: 6) + + Capsule() + .fill(Color.red) + .frame(width: 6, height: max(0, min(height, height * pct))) + + Circle() + .fill(Color.white) + .frame(width: 18, height: 18) + .shadow(color: Color.black.opacity(0.25), radius: 2) + .offset(y: -height * pct + 9) + } + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .padding(.vertical, 9) + .animation(.interactiveSpring(response: 0.35, dampingFraction: 0.75), value: value) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { gesture in + let touchY = gesture.location.y + let percent = 1 - min(max(touchY / height, 0), 1) + value = Double(percent) * (range.upperBound - range.lowerBound) + range.lowerBound + } + ) + } + .frame(height: 140) + + Text(label) + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundColor(.primary) + } + .frame(width: 44) + } +} + +// MARK: - Экран настройки эквалайзера +struct EqualizerSettingsView: View { + @AppStorage("eq_enabled") private var isEqEnabled: Bool = false + @AppStorage("selected_eq_preset") private var selectedPresetName: String = "Обычный (Flat)" + @AppStorage("appTheme") private var appTheme: AppTheme = .system + + @AppStorage("eq_band_0") private var band0: Double = 0.0 + @AppStorage("eq_band_1") private var band1: Double = 0.0 + @AppStorage("eq_band_2") private var band2: Double = 0.0 + @AppStorage("eq_band_3") private var band3: Double = 0.0 + @AppStorage("eq_band_4") private var band4: Double = 0.0 + @AppStorage("eq_band_5") private var band5: Double = 0.0 + @AppStorage("eq_band_6") private var band6: Double = 0.0 + @AppStorage("eq_band_7") private var band7: Double = 0.0 + @AppStorage("eq_band_8") private var band8: Double = 0.0 + @AppStorage("eq_band_9") private var band9: Double = 0.0 + + private let frequencies = ["32Гц", "64Гц", "125Гц", "250Гц", "500Гц", "1кГц", "2кГц", "4кГц", "8кГц", "16кГц"] + + private func bandBinding(for index: Int) -> Binding { + Binding( + get: { + switch index { + case 0: return band0; case 1: return band1; case 2: return band2 + case 3: return band3; case 4: return band4; case 5: return band5 + case 6: return band6; case 7: return band7; case 8: return band8 + case 9: return band9; default: return 0.0 + } + }, + set: { newValue in + switch index { + case 0: band0 = newValue; case 1: band1 = newValue; case 2: band2 = newValue + case 3: band3 = newValue; case 4: band4 = newValue; case 5: band5 = newValue + case 6: band6 = newValue; case 7: band7 = newValue; case 8: band8 = newValue + case 9: band9 = newValue; default: break + } + selectedPresetName = "Вручную" + MusicManager.shared.applyEQChanges() + } + ) + } + + var body: some View { + Form { + Section { + Toggle("Включить эквалайзер", isOn: $isEqEnabled) + .onChange(of: isEqEnabled) { _ in + MusicManager.shared.applyEQChanges() + } + } + + if isEqEnabled { + Section(header: Text("Параметрические полосы")) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(0..<10, id: \.self) { index in + VerticalSlider(value: bandBinding(for: index), label: frequencies[index]) + } + } + .padding(.vertical, 10) + } + } + + Section(header: Text("Популярные пресеты")) { + List { + ForEach(eqPresets) { preset in + HStack { + Text(preset.name) + .font(.body) + .foregroundColor(.primary) + Spacer() + if selectedPresetName == preset.name { + Image(systemName: "checkmark") + .foregroundColor(.red) + .font(.subheadline.bold()) + } + } + .contentShape(Rectangle()) + .onTapGesture { + applyPreset(preset) + } + } + } + } + } + } + .navigationTitle("Эквалайзер") + .navigationBarTitleDisplayMode(.inline) + .preferredColorScheme(appTheme.colorScheme) + } + + private func applyPreset(_ preset: EQPreset) { + withAnimation { + selectedPresetName = preset.name + band0 = preset.gains[0]; band1 = preset.gains[1]; band2 = preset.gains[2] + band3 = preset.gains[3]; band4 = preset.gains[4]; band5 = preset.gains[5] + band6 = preset.gains[6]; band7 = preset.gains[7]; band8 = preset.gains[8] + band9 = preset.gains[9] + } + MusicManager.shared.applyEQChanges() + } +} + +// MARK: - Синхронизированный просмотр текста +struct LyricsView: View { + let lyrics: [LyricLine] + let currentTime: TimeInterval + var isImmersive: Bool = false + var onSeek: ((TimeInterval) -> Void)? = nil + var onScroll: (() -> Void)? = nil + var onTap: (() -> Void)? = nil + + @AppStorage("lyricsFontSize") private var lyricsFontSize: Double = 34.0 + @AppStorage("karaokeModeEnabled") private var karaokeModeEnabled: Bool = true + + @State private var isUserScrolling = false + @State private var resetTask: DispatchWorkItem? = nil + + private var activeIndex: Int? { + guard lyrics.contains(where: { $0.time >= 0 }) else { return nil } + var result: Int? = nil + for (i, line) in lyrics.enumerated() where line.time >= 0 && line.time <= currentTime { + result = i + } + return result + } + + private var displayIndex: Int? { isUserScrolling ? nil : activeIndex } + + private func opacity(for i: Int) -> Double { + guard let cur = displayIndex else { return 0.65 } + switch abs(i - cur) { + case 0: return 1.0 + case 1: return 0.5 + case 2: return 0.3 + default: return 0.15 + } + } + + private func blurRadius(for i: Int) -> CGFloat { + guard let cur = displayIndex else { return 0 } + switch abs(i - cur) { + case 0: return 0 + case 1: return 0.8 + case 2: return 1.5 + default: return 2.2 + } + } + + private func scale(for i: Int) -> CGFloat { + guard let cur = displayIndex else { return 1 } + switch abs(i - cur) { + case 0: return 1.08 + default: return 1.0 + } + } + + private func progress(for index: Int) -> Double { + let currentLineTime = lyrics[index].time + let nextLineTime: Double + + if index + 1 < lyrics.count, lyrics[index + 1].time >= 0 { + nextLineTime = lyrics[index + 1].time + } else { + nextLineTime = currentLineTime + 8.0 + } + + let duration = min(nextLineTime - currentLineTime, 8.0) + guard duration > 0 else { return 1.0 } + + let p = (currentTime - currentLineTime) / duration + return max(0, p) + } + + private func karaokeText(for line: String, progress: Double, isActiveLine: Bool) -> Text { + guard isActiveLine && karaokeModeEnabled else { + return Text(line).foregroundColor(.primary) + } + + let chars = Array(line) + let total = Double(max(1, chars.count)) + var result = Text("") + + for (index, char) in chars.enumerated() { + let start = Double(index) / total + let end = Double(index + 1) / total + + let opacity: Double + if progress <= start { + opacity = 0.35 + } else if progress >= end { + opacity = 1.0 + } else { + let fillRatio = (progress - start) / (end - start) + opacity = 0.35 + (0.65 * fillRatio) + } + + result = result + Text(String(char)).foregroundColor(Color.primary.opacity(opacity)) + } + return result + } + + private func karaokeGlowText(for line: String, progress: Double) -> Text { + let chars = Array(line) + let total = Double(max(1, chars.count)) + var result = Text("") + + for (index, char) in chars.enumerated() { + let start = Double(index) / total + let end = Double(index + 1) / total + let center = (start + end) / 2.0 + + let glowSpread = max(0.15, 2.5 / total) + let distance = abs(progress - center) + let opacity: Double = distance < glowSpread ? 1.0 - (distance / glowSpread) : 0.0 + + result = result + Text(String(char)).foregroundColor(Color.primary.opacity(opacity)) + } + return result + } + + private func scheduleReset() { + resetTask?.cancel() + let task = DispatchWorkItem { + withAnimation(.easeInOut(duration: 0.4)) { isUserScrolling = false } + } + resetTask = task + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5, execute: task) + } + + var body: some View { + GeometryReader { geo in + ScrollViewReader { proxy in + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 24) { + ForEach(Array(lyrics.enumerated()), id: \.element.id) { i, line in + let isActive = displayIndex == i + let currentProgress = progress(for: i) + + ZStack { + karaokeText(for: line.text, progress: currentProgress, isActiveLine: isActive) + + if isActive && karaokeModeEnabled { + karaokeGlowText(for: line.text, progress: currentProgress) + .blur(radius: 7) + .opacity(0.85) + .scaleEffect(1.02) + } + } + .font(.system(size: CGFloat(lyricsFontSize), weight: .heavy, design: .rounded)) + .lineSpacing(4) + .multilineTextAlignment(.leading) + .padding(.horizontal, 30) + .opacity(opacity(for: i)) + .blur(radius: blurRadius(for: i)) + .scaleEffect(scale(for: i), anchor: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .id(line.id) + .animation(.spring(response: 0.38, dampingFraction: 0.78), value: displayIndex) + .onTapGesture { + guard line.time >= 0 else { + onTap?() + return + } + onSeek?(line.time) + onTap?() + resetTask?.cancel() + withAnimation(.easeInOut(duration: 0.3)) { isUserScrolling = false } + } + } + } + .padding(.vertical, geo.size.height * 0.4) // Динамический отступ вместо статичного + .frame(maxWidth: .infinity) + } + .background(Color.clear.contentShape(Rectangle()).onTapGesture { onTap?() }) + .simultaneousGesture( + DragGesture(minimumDistance: 8) + .onChanged { _ in + onScroll?() + resetTask?.cancel() + if !isUserScrolling { + withAnimation(.easeInOut(duration: 0.25)) { isUserScrolling = true } + } + } + .onEnded { _ in + onScroll?() + scheduleReset() + } + ) + .onChange(of: activeIndex) { newIndex in + guard !isUserScrolling, let idx = newIndex, lyrics.indices.contains(idx) else { return } + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + proxy.scrollTo(lyrics[idx].id, anchor: .center) + } + } + .onChange(of: isUserScrolling) { scrolling in + guard !scrolling, let idx = activeIndex, lyrics.indices.contains(idx) else { return } + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + proxy.scrollTo(lyrics[idx].id, anchor: .center) + } + } + } + } + } +} + +// MARK: - Редактор текста +struct LyricsEditorView: View { + @Environment(\.dismiss) var dismiss + @AppStorage("appTheme") private var appTheme: AppTheme = .system + @ObservedObject var lyricsManager = LyricsManager.shared + + let trackURL: URL + let trackTitle: String + let trackArtist: String + + @State private var editorText: String = "" + @State private var showLRCPicker = false + @State private var showDeleteAlert = false + @State private var importBanner: String? = nil + @State private var isFetchingOnline = false + + init(trackURL: URL, trackTitle: String, trackArtist: String) { + self.trackURL = trackURL + self.trackTitle = trackTitle + self.trackArtist = trackArtist + UITextView.appearance().backgroundColor = .clear + } + + var body: some View { + NavigationView { + VStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + Button(action: fetchOnline) { + if isFetchingOnline { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .frame(width: 20, height: 20) + } else { + Label("Найти в сети", systemImage: "network") + } + } + .font(.subheadline.weight(.medium)) + .foregroundColor(.white) + .padding(.horizontal, 14).padding(.vertical, 8) + .background(Color.blue) + .cornerRadius(10) + .disabled(isFetchingOnline) + + Button(action: { showLRCPicker = true }) { + Label("Импорт LRC", systemImage: "doc.badge.plus") + .font(.subheadline.weight(.medium)) + .foregroundColor(.white) + .padding(.horizontal, 14).padding(.vertical, 8) + .background(Color.red) + .cornerRadius(10) + } + + if !editorText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Button(action: { showDeleteAlert = true }) { + Image(systemName: "trash") + .foregroundColor(.white) + .padding(9) + .background(Color.gray.opacity(0.5)) + .cornerRadius(10) + } + } + Spacer() + if editorText.contains("[") && editorText.contains(":") { + Label("LRC", systemImage: "timer") + .font(.caption.weight(.semibold)) + .foregroundColor(.orange) + .padding(.horizontal, 8).padding(.vertical, 4) + .background(Color.orange.opacity(0.12)) + .cornerRadius(6) + } + } + .padding(.horizontal) + } + .padding(.vertical, 12) + + if let banner = importBanner { + HStack(spacing: 6) { + Image(systemName: "info.circle.fill").foregroundColor(.blue) + Text(banner).font(.caption).foregroundColor(.blue) + Spacer() + } + .padding(.horizontal).padding(.bottom, 8) + .transition(.opacity) + } + + Divider() + + ZStack(alignment: .topLeading) { + if editorText.isEmpty { + Text("Введите текст песни вручную, или вставьте LRC-содержимое с временными метками вида [01:23.45]…") + .foregroundColor(.gray.opacity(0.6)) + .font(.body) + .padding(.horizontal, 18).padding(.top, 14) + } + TextEditor(text: $editorText) + .font(.body) + .padding(.horizontal, 12).padding(.top, 6) + .background(Color.clear) + } + .background(Color(UIColor.systemBackground)) + } + .navigationTitle(trackTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Отмена") { dismiss() } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: saveAndDismiss) { + Text("Сохранить").fontWeight(.bold) + } + } + } + .alert("Очистить текст?", isPresented: $showDeleteAlert) { + Button("Отмена", role: .cancel) {} + Button("Очистить", role: .destructive) { + editorText = "" + lyricsManager.deleteLyrics(for: trackURL) + } + } + .fileImporter( + isPresented: $showLRCPicker, + allowedContentTypes: [UTType(filenameExtension: "lrc") ?? .plainText], + allowsMultipleSelection: false + ) { result in + importLRCFile(result: result) + } + } + .onAppear { + let lines = lyricsManager.lyrics(for: trackURL) + editorText = lines.isEmpty ? "" : lyricsManager.lrcText(for: lines) + } + .preferredColorScheme(appTheme.colorScheme) + } + + private func saveAndDismiss() { + let trimmed = editorText.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + lyricsManager.deleteLyrics(for: trackURL) + } else if trimmed.contains("[") { + let parsed = lyricsManager.parseLRC(trimmed) + if parsed.contains(where: { $0.time >= 0 }) { + lyricsManager.setLyrics(parsed, for: trackURL) + } else { + savePlainText(trimmed) + } + } else { + savePlainText(trimmed) + } + dismiss() + } + + private func savePlainText(_ text: String) { + let lines = text.components(separatedBy: "\n") + .map { LyricLine(time: -1, text: $0) } + lyricsManager.setLyrics(lines, for: trackURL) + } + + private func importLRCFile(result: Result<[URL], Error>) { + guard case .success(let urls) = result, let url = urls.first else { return } + let accessing = url.startAccessingSecurityScopedResource() + defer { if accessing { url.stopAccessingSecurityScopedResource() } } + guard let content = try? String(contentsOf: url, encoding: .utf8) else { return } + let lines = lyricsManager.parseLRC(content) + lyricsManager.setLyrics(lines, for: trackURL) + editorText = lyricsManager.lrcText(for: lines) + withAnimation { importBanner = "Импортирован: \(url.lastPathComponent)" } + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + withAnimation { importBanner = nil } + } + } + + private func fetchOnline() { + isFetchingOnline = true + Task { + let foundLyrics = await lyricsManager.fetchLyricsFromLRCLib(title: trackTitle, artist: trackArtist) + + await MainActor.run { + isFetchingOnline = false + if let newText = foundLyrics, !newText.isEmpty { + editorText = newText + withAnimation { importBanner = "Текст успешно загружен!" } + } else { + withAnimation { importBanner = "К сожалению, ничего не найдено 😔" } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { + withAnimation { importBanner = nil } + } + } + } + } +} + +// MARK: - Редактор метаданных трека +struct MetadataEditorView: View { + @Environment(\.dismiss) var dismiss + @AppStorage("appTheme") private var appTheme: AppTheme = .system + @ObservedObject var manager = MusicManager.shared + @ObservedObject var metadataManager = TrackMetadataManager.shared + @ObservedObject var artworkManager = ArtworkManager.shared + + let trackURL: URL + + @State private var editedTitle: String = "" + @State private var editedArtist: String = "" + @State private var previewArtwork: UIImage? = nil + @State private var showResetAlert = false + @State private var showImagePicker = false + + private var isSaveDisabled: Bool { + editedTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + var body: some View { + NavigationView { + Form { + Section(header: Text("Обложка")) { + HStack(spacing: 16) { + ZStack { + RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.15)) + if let image = previewArtwork { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "music.note") + .foregroundColor(.secondary) + } + } + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipped() + + VStack(alignment: .leading, spacing: 10) { + Button(action: { showImagePicker = true }) { + Label("Выбрать из галереи", systemImage: "photo") + } + if artworkManager.hasCustomArtwork(for: trackURL) { + Button(role: .destructive, action: removeArtwork) { + Label("Удалить обложку", systemImage: "trash") + } + } + } + .font(.subheadline) + Spacer() + } + } + + Section(header: Text("Метаданные трека")) { + TextField("Название", text: $editedTitle) + TextField("Исполнитель", text: $editedArtist) + } + + Section(header: Text("Файл")) { + InfoRow(title: "Имя файла", value: trackURL.lastPathComponent) + InfoRow(title: "Формат", value: trackURL.pathExtension.uppercased()) + } + + if metadataManager.hasOverride(for: trackURL) { + Section { + Button(role: .destructive, action: { showResetAlert = true }) { + Label("Сбросить к оригинальным данным", systemImage: "arrow.counterclockwise") + } + } + } + } + .navigationTitle("Редактировать метаданные") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Отмена") { dismiss() } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: saveAndDismiss) { + Text("Сохранить").fontWeight(.bold) + } + .disabled(isSaveDisabled) + } + } + .alert("Сбросить метаданные?", isPresented: $showResetAlert) { + Button("Отмена", role: .cancel) {} + Button("Сбросить", role: .destructive) { + manager.resetMetadata(for: trackURL) + let original = manager.originalMetadata(for: trackURL) + editedTitle = original.title + editedArtist = original.artist + dismiss() + } + } message: { + Text("Название и исполнитель вернутся к значениям, прочитанным из файла.") + } + .sheet(isPresented: $showImagePicker) { + ImagePicker { image in + previewArtwork = image + manager.updateArtwork(image, for: trackURL) + } + } + } + .onAppear { + if let override = metadataManager.override(for: trackURL) { + editedTitle = override.title + editedArtist = override.artist + } else { + let original = manager.originalMetadata(for: trackURL) + editedTitle = original.title + editedArtist = original.artist + } + loadPreviewArtwork() + } + .preferredColorScheme(appTheme.colorScheme) + } + + private func saveAndDismiss() { + manager.updateMetadata(title: editedTitle, artist: editedArtist, for: trackURL) + dismiss() + } + + private func removeArtwork() { + manager.removeCustomArtwork(for: trackURL) + loadPreviewArtwork() + } + + private func loadPreviewArtwork() { + if let custom = artworkManager.artwork(for: trackURL) { + previewArtwork = custom + return + } + previewArtwork = nil + DispatchQueue.global(qos: .userInitiated).async { + let asset = AVAsset(url: trackURL) + for item in asset.metadata where item.commonKey == .commonKeyArtwork { + if let data = item.dataValue, let img = UIImage(data: data) { + DispatchQueue.main.async { self.previewArtwork = img } + return + } + } + } + } +} + +struct TrackRow: View { + let track: Track + let isCurrent: Bool + let isPlaying: Bool + @State private var artwork: UIImage? + @ObservedObject private var lyricsManager = LyricsManager.shared + + var body: some View { + HStack(spacing: 16) { + ZStack { + RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.15)) + if let image = artwork { + Image(uiImage: image).resizable().aspectRatio(contentMode: .fill) + } else { + Image(systemName: "music.note").foregroundColor(.gray).font(.system(size: 18)) + } + if lyricsManager.hasLyrics(for: track.url) { + VStack { + HStack { + Spacer() + Image(systemName: "quote.bubble.fill") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(.white) + .padding(4) + .background(Color.black.opacity(0.6)) + .clipShape(Circle()) + } + Spacer() + } + .padding(3) + } + } + .frame(width: 50, height: 50).cornerRadius(8).clipped() + .shadow(color: Color.black.opacity(0.1), radius: 3, y: 2) + + VStack(alignment: .leading, spacing: 3) { + Text(track.title) + .font(.system(size: 16, weight: isCurrent ? .semibold : .regular)) + .foregroundColor(isCurrent ? .red : .primary) + .lineLimit(1) + Text(track.artist) + .font(.system(size: 14)) + .foregroundColor(.secondary) + .lineLimit(1) + } + Spacer() + if isCurrent && isPlaying { + Image(systemName: "waveform").foregroundColor(.red) + } + } + .padding(.vertical, 4) + .onAppear { + if let custom = ArtworkManager.shared.artwork(for: track.url) { + self.artwork = custom + return + } + DispatchQueue.global(qos: .userInteractive).async { + let asset = AVAsset(url: track.url) + for item in asset.metadata where item.commonKey == .commonKeyArtwork { + if let data = item.dataValue, let img = UIImage(data: data) { + DispatchQueue.main.async { self.artwork = img } + } + } + } + } + } +} + + +struct MiniPlayer: View { + @ObservedObject var manager = MusicManager.shared + @Binding var showFullPlayer: Bool + + var body: some View { + VStack(spacing: 0) { + Divider().background(Color.primary.opacity(0.1)) + HStack(spacing: 14) { + ZStack { + if let image = manager.currentArtwork { + Image(uiImage: image).resizable().aspectRatio(contentMode: .fill) + } else { + Color.primary.opacity(0.1) + Image(systemName: "music.note").foregroundColor(.gray) + } + } + .frame(width: 48, height: 48).cornerRadius(6).clipped() + .shadow(color: manager.gradientColors.first?.opacity(0.4) ?? .black.opacity(0.2), radius: 5, y: 3) + + VStack(alignment: .leading, spacing: 2) { + Text(manager.currentTitle) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .lineLimit(1) + Text(manager.currentArtist) + .font(.system(size: 13, weight: .regular)) + .foregroundColor(.secondary) + .lineLimit(1) + } + Spacer() + HStack(spacing: 20) { + Button(action: manager.playPause) { + Image(systemName: manager.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 22)) + } + Button(action: manager.nextTrack) { + Image(systemName: "forward.fill") + .font(.system(size: 22)) + } + } + .foregroundColor(.primary) + } + .padding(.horizontal, 20).frame(height: 64) + .padding(.bottom, 8) + } + .background(BlurView(style: .systemChromeMaterial).ignoresSafeArea(edges: .bottom)) + .onTapGesture { showFullPlayer = true } + } +} + +struct ImmersiveBottomBar: View { + @ObservedObject var manager: MusicManager + + var body: some View { + HStack(spacing: 14) { + if let image = manager.currentArtwork { + Image(uiImage: image).resizable().aspectRatio(contentMode: .fill) + .frame(width: 48, height: 48).cornerRadius(6) + } else { + RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.2)) + .frame(width: 48, height: 48) + .overlay(Image(systemName: "music.note").foregroundColor(.white.opacity(0.5))) + } + + VStack(alignment: .leading, spacing: 2) { + Text(manager.currentTitle) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + Text(manager.currentArtist) + .font(.system(size: 14)) + .foregroundColor(.white.opacity(0.7)) + .lineLimit(1) + } + + Spacer() + + Button(action: manager.playPause) { + Image(systemName: manager.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 38)) + .foregroundColor(.white) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(Color.black.opacity(0.4).edgesIgnoringSafeArea(.bottom)) + .background(BlurView(style: .systemMaterialDark).edgesIgnoringSafeArea(.bottom)) + } +} + +// MARK: - Главный экран +struct ContentView: View { + @StateObject var manager = MusicManager.shared + @AppStorage("appTheme") private var appTheme: AppTheme = .system + + @State private var showFilePicker = false + @State private var showFullPlayer = false + @State private var showSettings = false + @State private var searchText = "" + + @State private var shareURL: URL? + + @State private var isFetchingMassLyrics = false + @State private var processedTracksCount = 0 + @State private var totalTracksToFetch = 0 + @State private var foundLyricsCount = 0 + @State private var showMassFetchResult = false + + var body: some View { + ZStack(alignment: .bottom) { + NavigationView { + ZStack { + Color(UIColor.systemBackground).ignoresSafeArea() + + if manager.tracks.isEmpty { + ZStack { + VStack(spacing: 16) { + Image(systemName: "music.note.list") + .font(.system(size: 70, weight: .light)) + .foregroundColor(.secondary) + Text("Медиатека пуста") + .font(.title2.weight(.semibold)) + .foregroundColor(.primary) + Text("Добавьте музыку, нажав на '+'") + .font(.subheadline) + .foregroundColor(.secondary) + } + + VStack { + Spacer() + Text("by aswer.") + .font(.system(size: 10, weight: .regular, design: .rounded)) + .foregroundColor(.secondary.opacity(0.4)) + .padding(.bottom, 20) + } + } + } else { + List { + ForEach(manager.tracks.filter { track in + if searchText.isEmpty { return true } + let matchTitle = track.title.localizedCaseInsensitiveContains(searchText) + let matchArtist = track.artist.localizedCaseInsensitiveContains(searchText) + return matchTitle || matchArtist + }, id: \.self) { track in + TrackRow(track: track, isCurrent: manager.audioPlayer?.url == track.url, isPlaying: manager.isPlaying) + .contentShape(Rectangle()) + .onTapGesture { + manager.shuffleOn = false + if let i = manager.tracks.firstIndex(of: track) { manager.playTrack(at: i, in: manager.tracks) } + } + .contextMenu { + Button(action: { shareURL = track.url }) { Label("Поделиться", systemImage: "square.and.arrow.up") } + Button(role: .destructive, action: { manager.deleteTrack(track: track) }) { Label("Удалить", systemImage: "trash") } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { manager.deleteTrack(track: track) } label: { Label("Удалить", systemImage: "trash") } + Button { shareURL = track.url } label: { Label("Поделиться", systemImage: "square.and.arrow.up") }.tint(.blue) + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 4, leading: 20, bottom: 4, trailing: 20)) + .listRowSeparator(.visible) + } + .onDelete(perform: manager.deleteTrack) + + Color.clear + .frame(height: 85) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + + Text("by aswer.") + .font(.system(size: 10, weight: .regular, design: .rounded)) + .foregroundColor(.secondary.opacity(0.4)) + .frame(maxWidth: .infinity, alignment: .center) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .padding(.bottom, 20) + } + .listStyle(.plain) + .disabled(isFetchingMassLyrics) + } + + if isFetchingMassLyrics { + ZStack { + Color.black.opacity(0.4).ignoresSafeArea() + BlurView(style: .systemThinMaterialDark).ignoresSafeArea() + + VStack(spacing: 24) { + ProgressView() + .scaleEffect(1.8) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + + VStack(spacing: 8) { + Text("Ищем тексты...") + .font(.headline) + .foregroundColor(.white) + + Text("\(processedTracksCount) из \(totalTracksToFetch)") + .font(.subheadline) + .foregroundColor(.white.opacity(0.7)) + } + } + .padding(40) + .background(Color(UIColor.secondarySystemBackground).opacity(0.15)) + .cornerRadius(24) + } + .zIndex(100) + } + } + .navigationTitle("Медиатека") + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + HStack(spacing: 16) { + Button(action: { showFilePicker = true }) { + Image(systemName: "plus.circle.fill") + .foregroundColor(.red) + .font(.title3) + } + + Menu { + Button(action: fetchLyricsForAll) { + Label("Найти тексты для всех", systemImage: "text.magnifyingglass") + } + Button(action: { showSettings = true }) { + Label("Настройки", systemImage: "gearshape") + } + } label: { + Image(systemName: "ellipsis.circle.fill") + .foregroundColor(.red) + .font(.title3) + } + } + } + } + .alert("Результаты поиска", isPresented: $showMassFetchResult) { + Button("Завершить поиск", role: .cancel) { } + } message: { + if totalTracksToFetch == 0 { + Text("Для всех твоих треков тексты уже есть.") + } else { + Text("Обработано треков: \(totalTracksToFetch)\nУспешно найдено: \(foundLyricsCount)\nНе найдено : \(totalTracksToFetch - foundLyricsCount)") + } + } + } + .navigationViewStyle(StackNavigationViewStyle()) + + if !manager.tracks.isEmpty { MiniPlayer(showFullPlayer: $showFullPlayer) } + } + .preferredColorScheme(appTheme.colorScheme) + .fullScreenCover(isPresented: $showFullPlayer) { FullPlayerView() } + .sheet(isPresented: $showSettings) { SettingsView() } + .sheet(item: $shareURL) { url in + ActivityViewController(activityItems: [url]) + } + .fileImporter( + isPresented: $showFilePicker, + allowedContentTypes: [.audio, UTType(filenameExtension: "lrc") ?? .plainText], + allowsMultipleSelection: true + ) { res in + if case .success(let urls) = res { manager.importTracks(from: urls) } + } + } + + private func fetchLyricsForAll() { + let tracksToProcess = manager.tracks.filter { !LyricsManager.shared.hasLyrics(for: $0.url) } + + guard !tracksToProcess.isEmpty else { + totalTracksToFetch = 0 + foundLyricsCount = 0 + showMassFetchResult = true + return + } + + isFetchingMassLyrics = true + totalTracksToFetch = tracksToProcess.count + processedTracksCount = 0 + foundLyricsCount = 0 + + Task { + for track in tracksToProcess { + let foundLyrics = await LyricsManager.shared.fetchLyricsFromLRCLib(title: track.title, artist: track.artist) + + if let newText = foundLyrics, !newText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + let trimmed = newText.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.contains("[") { + let parsed = LyricsManager.shared.parseLRC(trimmed) + if parsed.contains(where: { $0.time >= 0 }) { + LyricsManager.shared.setLyrics(parsed, for: track.url) + } else { + let lines = trimmed.components(separatedBy: "\n").map { LyricLine(time: -1, text: $0) } + LyricsManager.shared.setLyrics(lines, for: track.url) + } + } else { + let lines = trimmed.components(separatedBy: "\n").map { LyricLine(time: -1, text: $0) } + LyricsManager.shared.setLyrics(lines, for: track.url) + } + + await MainActor.run { foundLyricsCount += 1 } + } + + await MainActor.run { processedTracksCount += 1 } + } + + await MainActor.run { + isFetchingMassLyrics = false + showMassFetchResult = true + } + } + } +} + +extension URL: Identifiable { + public var id: String { self.absoluteString } +} + +// MARK: - Окно настроек +struct SettingsView: View { + @AppStorage("appTheme") private var appTheme: AppTheme = .system + @AppStorage("controlSystemVolume") private var controlSystemVolume: Bool = false + + @AppStorage("eq_enabled") private var isEqEnabled: Bool = false + @AppStorage("selected_eq_preset") private var selectedPresetName: String = "Обычный (Flat)" + + @AppStorage("karaokeModeEnabled") private var karaokeModeEnabled: Bool = true + @AppStorage("autoImmersiveLyricsEnabled") private var autoImmersiveLyricsEnabled: Bool = true + @AppStorage("autoImmersiveLyricsTimeout") private var autoImmersiveLyricsTimeout: Double = 3.0 + @AppStorage("lyricsFontSize") private var lyricsFontSize: Double = 34.0 + + @AppStorage("useSimpleBackground") private var useSimpleBackground: Bool = false + + @Environment(\.dismiss) var dismiss + @State private var showingClearAlert = false + @ObservedObject var manager = MusicManager.shared + + var body: some View { + NavigationView { + Form { + Section(header: Text("Внешний вид")) { + Picker("Тема оформления", selection: $appTheme) { + ForEach(AppTheme.allCases, id: \.self) { theme in + Text(theme.title).tag(theme) + } + } + .pickerStyle(SegmentedPickerStyle()) + + Toggle(isOn: $useSimpleBackground) { + VStack(alignment: .leading, spacing: 4) { + Text("Упрощенный фон плеера") + Text("Отключает тяжелую анимацию и показывает статичную размытую обложку") + .font(.caption) + .foregroundColor(.gray) + } + } + } + + Section(header: Text("Звуковые эффекты")) { + NavigationLink(destination: EqualizerSettingsView()) { + HStack { + Label("Эквалайзер", systemImage: "slider.horizontal.3") + .foregroundColor(.red) + Spacer() + Text(isEqEnabled ? selectedPresetName : "Выкл.") + .foregroundColor(.gray) + } + } + } + + Section(header: Text("Текст песни")) { + Toggle("Караоке-режим", isOn: $karaokeModeEnabled) + Toggle("Иммерсивный режим", isOn: $autoImmersiveLyricsEnabled) + if autoImmersiveLyricsEnabled { + VStack(alignment: .leading, spacing: 6) { + Text("Задержка включения: \(Int(autoImmersiveLyricsTimeout)) сек.") + .font(.body) + Slider(value: $autoImmersiveLyricsTimeout, in: 1...15, step: 1) + .accentColor(.red) + Text("Через какое время бездействия скрывать плеер и оставлять только текст") + .font(.caption) + .foregroundColor(.gray) + } + .padding(.vertical, 4) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Размер шрифта: \(Int(lyricsFontSize)) пт") + .font(.body) + Slider(value: $lyricsFontSize, in: 18...48, step: 2) + .accentColor(.red) + Text("Настройте размер отображаемого текста на экране воспроизведения") + .font(.caption) + .foregroundColor(.gray) + } + .padding(.vertical, 4) + } + + Section(header: Text("Громкость")) { + Toggle(isOn: $controlSystemVolume) { + VStack(alignment: .leading, spacing: 4) { + Text("Системная громкость iPhone") + Text("Управлять общей громкостью устройства вместо громкости внутри плеера") + .font(.caption) + .foregroundColor(.gray) + } + } + .onChange(of: controlSystemVolume) { _ in + manager.syncVolumeWithCurrentMode() + } + } + + Section(header: Text("Управление")) { + Button(role: .destructive, action: { + showingClearAlert = true + }) { + Text("Очистить медиатеку") + } + } + } + .navigationTitle("Настройки") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Готово") { dismiss() } + } + } + .alert("Очистить медиатеку?", isPresented: $showingClearAlert) { + Button("Отмена", role: .cancel) { } + Button("Удалить", role: .destructive) { + manager.deleteAllTracks() + dismiss() + } + } message: { + Text("Вы точно хотите удалить все треки? Это действие нельзя отменить.") + } + } + .preferredColorScheme(appTheme.colorScheme) + } +} + +// MARK: - Извлечение доминирующих цветов из обложки +extension UIImage { + func extractGradientColors() -> [Color] { + let side = 16 + var raw = [UInt8](repeating: 0, count: side * side * 4) + guard let ctx = CGContext( + data: &raw, + width: side, height: side, + bitsPerComponent: 8, + bytesPerRow: side * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ), let cgImg = self.cgImage else { + return [Color(red: 0.1, green: 0.1, blue: 0.2), Color(red: 0.2, green: 0.1, blue: 0.3)] + } + ctx.draw(cgImg, in: CGRect(x: 0, y: 0, width: side, height: side)) + + let pts: [(Int,Int)] = [ + (1,1), (14,1), (7,7), (1,14), (14,14), (7,1), (7,14) + ] + return pts.map { (x, y) in + let off = (y * side + x) * 4 + let r = CGFloat(raw[off]) / 255 + let g = CGFloat(raw[off+1]) / 255 + let b = CGFloat(raw[off+2]) / 255 + var h: CGFloat = 0, s: CGFloat = 0, br: CGFloat = 0, a: CGFloat = 0 + UIColor(red: r, green: g, blue: b, alpha: 1) + .getHue(&h, saturation: &s, brightness: &br, alpha: &a) + return Color(UIColor( + hue: h, + saturation: min(s * 1.5, 1.0), + brightness: max(min(br, 0.7), 0.25), + alpha: 1 + )) + } + } +} + +// MARK: - Анимированный градиентный фон в стиле Apple Music +struct AppleMusicGradientBackground: View { + let colors: [Color] + + var body: some View { + TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in + AnimatedBlobCanvas( + colors: colors, + time: timeline.date.timeIntervalSinceReferenceDate + ) + } + .ignoresSafeArea() + } +} + +private struct AnimatedBlobCanvas: View { + let colors: [Color] + let time: Double + + private let configs: [(Double, Double, Double, Double, Double)] = [ + (0.41, 0.29, 0.00, 0.00, 1.15), + (0.57, 0.38, 2.09, 1.57, 1.00), + (0.33, 0.63, 4.19, 0.79, 1.25), + (0.69, 0.44, 1.05, 3.14, 0.90), + (0.48, 0.71, 3.14, 2.36, 1.05), + (0.62, 0.52, 5.24, 4.71, 0.95), + (0.36, 0.59, 2.62, 1.05, 1.10), + ] + + var body: some View { + GeometryReader { geo in + let w = geo.size.width + let h = geo.size.height + let base = max(w, h) * 0.50 + + ZStack { + Color.black.opacity(0.4) + + colors.first.map { $0.opacity(0.80) } + + ForEach(0.. = 0...1 + @State private var isDragging: Bool = false + + var body: some View { + GeometryReader { geo in + let trackHeight: CGFloat = isDragging ? 8 : 4 + let pct = CGFloat((value - range.lowerBound) / max(range.upperBound - range.lowerBound, 0.001)) + + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.2)) + .frame(height: trackHeight) + + Capsule() + .fill(Color.white.opacity(isDragging ? 0.9 : 0.7)) + .frame(width: max(0, min(geo.size.width, geo.size.width * pct)), height: trackHeight) + } + .position(x: geo.size.width / 2, y: geo.size.height / 2) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isDragging) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { v in + isDragging = true + let percent = min(max(v.location.x / geo.size.width, 0), 1) + value = Double(percent) * (range.upperBound - range.lowerBound) + range.lowerBound + } + .onEnded { _ in + isDragging = false + } + ) + } + .frame(height: 24) + } +} + +struct VolumeSliderView: View { + @Binding var value: Float + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "speaker.fill") + .font(.caption) + .foregroundColor(.white.opacity(0.6)) + + GeometryReader { geo in + let pct = CGFloat(value) + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.2)) + .frame(height: 5) + + Capsule() + .fill(Color.white) + .frame(width: max(0, min(geo.size.width, geo.size.width * pct)), height: 5) + } + .position(x: geo.size.width / 2, y: geo.size.height / 2) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { v in + let percent = min(max(v.location.x / geo.size.width, 0), 1) + value = Float(percent) + } + ) + } + .frame(height: 20) + + Image(systemName: "speaker.wave.3.fill") + .font(.caption) + .foregroundColor(.white.opacity(0.6)) + } + } +} + +struct FullPlayerView: View { + @Environment(\.dismiss) var dismiss + + @AppStorage("autoImmersiveLyricsEnabled") private var autoImmersiveLyricsEnabled: Bool = true + @AppStorage("autoImmersiveLyricsTimeout") private var autoImmersiveLyricsTimeout: Double = 3.0 + @AppStorage("useSimpleBackground") private var useSimpleBackground: Bool = false + + @ObservedObject var manager = MusicManager.shared + @ObservedObject var lyricsManager = LyricsManager.shared + @ObservedObject var playbackTime = PlaybackTime.shared + + @State private var showFileInfo = false + @State private var showShareSheet = false + @State private var showLyrics = false + @State private var showLyricsEditor = false + @State private var showMetadataEditor = false + + @State private var isImmersiveLyrics = false + @State private var idleTask: DispatchWorkItem? = nil + + private func resetIdleTimer() { + idleTask?.cancel() + guard showLyrics else { + if isImmersiveLyrics { withAnimation { isImmersiveLyrics = false } } + return + } + + guard autoImmersiveLyricsEnabled else { return } + + let task = DispatchWorkItem { + if self.showLyrics { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + self.isImmersiveLyrics = true + } + } + } + idleTask = task + DispatchQueue.main.asyncAfter(deadline: .now() + autoImmersiveLyricsTimeout, execute: task) + } + + private func handleTap() { + if isImmersiveLyrics { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + isImmersiveLyrics = false + } + } + resetIdleTimer() + } + + private func handleScroll() { + if showLyrics && !isImmersiveLyrics && autoImmersiveLyricsEnabled { + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + isImmersiveLyrics = true + } + } + resetIdleTimer() + } + + var body: some View { + GeometryReader { geo in + let isLandscape = geo.size.width > geo.size.height + let isSmall = min(geo.size.height, geo.size.width) < 670 + + ZStack { + // Фоновая подложка + Color.black.ignoresSafeArea() + + // Градиенты или обложка на фоне + if useSimpleBackground { + GeometryReader { bgGeo in + if let image = manager.currentArtwork { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: bgGeo.size.width, height: bgGeo.size.height) + .blur(radius: 80) + .clipped() + .opacity(0.6) + .animation(.easeInOut(duration: 1.0), value: manager.currentArtwork) + } else { + LinearGradient( + colors: manager.gradientColors, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .opacity(0.6) + .animation(.easeInOut(duration: 1.0), value: manager.gradientColors) + } + } + .ignoresSafeArea() + } else { + AppleMusicGradientBackground(colors: manager.gradientColors) + .animation(.easeInOut(duration: 1.5), value: manager.currentTrackIndex) + } + + BlurView(style: .systemUltraThinMaterialDark).opacity(0.40).ignoresSafeArea() + + // Главный контейнер + VStack(spacing: 0) { + // Капсула-Grabber (только если не иммерсивный режим) + if !isImmersiveLyrics { + Capsule() + .fill(Color.white.opacity(0.3)) + .frame(width: 36, height: 5) + .padding(.top, 8) + } + + if isLandscape { + // ГОРИЗОНТАЛЬНАЯ ОРИЕНТАЦИЯ + HStack(spacing: 0) { + artworkAndLyricsSection(geo: geo, isLandscape: true, isSmall: isSmall) + .frame(maxWidth: .infinity) + .padding(.horizontal, isImmersiveLyrics ? 0 : 20) + + if !isImmersiveLyrics { + playerControlsSection(isLandscape: true, isSmall: isSmall) + .frame(maxWidth: .infinity) + } + } + } else { + // ВЕРТИКАЛЬНАЯ ОРИЕНТАЦИЯ + artworkAndLyricsSection(geo: geo, isLandscape: false, isSmall: isSmall) + .padding(.top, isImmersiveLyrics ? 0 : (isSmall ? 10 : 30)) + .padding(.horizontal, isImmersiveLyrics ? 0 : (isSmall ? 24 : 20)) + + if !isImmersiveLyrics { + playerControlsSection(isLandscape: false, isSmall: isSmall) + } + } + } + .frame(width: geo.size.width) + + // Нижний бар для иммерсивного режима + if isImmersiveLyrics { + VStack { + Spacer() + ImmersiveBottomBar(manager: manager) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + } + .frame(width: geo.size.width, height: geo.size.height) + } + .preferredColorScheme(.dark) + .gesture(DragGesture().onEnded { if $0.translation.height > 100 { dismiss() } }) + .sheet(isPresented: $showFileInfo) { + if let currentUrl = manager.audioPlayer?.url { + FileInfoView(url: currentUrl, duration: manager.trackDuration) + } + } + .sheet(isPresented: $showShareSheet) { + if let currentTrack = manager.currentTrack { + ActivityViewController(activityItems: [currentTrack.url]) + } + } + .sheet(isPresented: $showLyricsEditor) { + if let track = manager.currentTrack { + LyricsEditorView(trackURL: track.url, trackTitle: track.title, trackArtist: track.artist) + } + } + .sheet(isPresented: $showMetadataEditor) { + if let track = manager.currentTrack { + MetadataEditorView(trackURL: track.url) + } + } + .onChange(of: manager.currentTrackIndex) { _ in + withAnimation { + showLyrics = false + isImmersiveLyrics = false + } + } + .onChange(of: autoImmersiveLyricsEnabled) { isEnabled in + if !isEnabled { + idleTask?.cancel() + withAnimation { + isImmersiveLyrics = false + } + } else { + resetIdleTimer() + } + } + .onChange(of: showLyrics) { _ in + resetIdleTimer() + } + .onAppear { + manager.syncVolumeWithCurrentMode() + resetIdleTimer() + } + } + + // Блок с обложкой или текстами + @ViewBuilder + private func artworkAndLyricsSection(geo: GeometryProxy, isLandscape: Bool, isSmall: Bool) -> some View { + let trackURL = manager.currentTrack?.url + let lyrics = trackURL.map { lyricsManager.lyrics(for: $0) } ?? [] + let boxSize = isLandscape ? geo.size.height * 0.75 : (isSmall ? geo.size.width * 0.75 : geo.size.width * 0.92) + + ZStack { + if showLyrics { + Group { + if lyrics.isEmpty { + VStack(spacing: 16) { + Image(systemName: "quote.bubble") + .font(.system(size: 46)) + .foregroundColor(.white.opacity(0.35)) + Text("Текст отсутствует") + .font(.headline) + .foregroundColor(.white.opacity(0.6)) + Button(action: { showLyricsEditor = true }) { + Label("Добавить текст", systemImage: "plus") + .font(.subheadline.weight(.medium)) + .foregroundColor(.white) + .padding(.horizontal, 18).padding(.vertical, 10) + .background(Color.white.opacity(0.15)) + .cornerRadius(10) + } + } + } else { + LyricsView( + lyrics: lyrics, + currentTime: playbackTime.currentTime, + isImmersive: isImmersiveLyrics, + onSeek: { manager.seek(to: $0) }, + onScroll: { handleScroll() }, + onTap: { handleTap() } + ) + } + } + .transition(.opacity) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ZStack { + RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.white.opacity(0.1)) + if let image = manager.currentArtwork { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "music.note") + .font(.system(size: isSmall ? 60 : 100)) + .foregroundColor(.white.opacity(0.5)) + } + } + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .scaleEffect(manager.isPlaying ? 1.0 : 0.85) + .animation(.spring(response: 0.6, dampingFraction: 0.65), value: manager.isPlaying) + .transition(.opacity) + .frame(width: boxSize, height: boxSize) // Жестко фиксируем обложку квадратом + } + } + // Вместо общего aspectRatio и жестких фреймов позволяем LyricsView полностью заполнить доступное место + .frame( + maxWidth: (showLyrics || isImmersiveLyrics) ? .infinity : boxSize, + maxHeight: (showLyrics || isImmersiveLyrics) ? .infinity : boxSize + ) + .animation(.spring(response: 0.55, dampingFraction: 0.82), value: isImmersiveLyrics) + .animation(.spring(response: 0.55, dampingFraction: 0.82), value: showLyrics) + .cornerRadius(isImmersiveLyrics ? 0 : 12) + .overlay(alignment: .topTrailing) { + if isImmersiveLyrics { + Button(action: handleTap) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 28)) + .foregroundColor(.white.opacity(0.6)) + .padding(24) + .padding(.top, isSmall ? 0 : 10) + } + } else if showLyrics && !lyrics.isEmpty { + Button(action: { showLyricsEditor = true }) { + Image(systemName: "pencil.circle.fill") + .font(.system(size: 26)) + .foregroundColor(.white.opacity(0.7)) + .padding(10) + } + } + } + .gesture( + DragGesture().onEnded { value in + guard !showLyrics else { return } + if value.translation.width < -50 { withAnimation { manager.nextTrack() } } + else if value.translation.width > 50 { withAnimation { manager.prevTrack() } } + } + ) + } + + // Блок с элементами управления плеером + @ViewBuilder + private func playerControlsSection(isLandscape: Bool, isSmall: Bool) -> some View { + VStack(spacing: 0) { + VStack(spacing: isLandscape ? 16 : (isSmall ? 22 : 34)) { + // Info & More Button + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 2) { + Text(manager.currentTitle) + .font(.system(size: isSmall ? 20 : 24, weight: .bold, design: .rounded)) + .lineLimit(1) + .minimumScaleFactor(0.8) + .foregroundColor(.white) + Text(manager.currentArtist) + .font(.system(size: isSmall ? 16 : 18, weight: .medium, design: .rounded)) + .foregroundColor(.white.opacity(0.6)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + Spacer() + + Menu { + Button(action: { showFileInfo = true }) { Label("Свойства файла", systemImage: "info.circle") } + Button(action: { + if manager.currentTrack != nil { showShareSheet = true } + }) { Label("Поделиться", systemImage: "square.and.arrow.up") } + Button(action: { + if manager.currentTrack != nil { showMetadataEditor = true } + }) { Label("Редактировать метаданные", systemImage: "tag") } + Button(action: { + if manager.currentTrack != nil { showLyricsEditor = true } + }) { Label("Редактировать текст", systemImage: "pencil") } + } label: { + Image(systemName: "ellipsis.circle.fill") + .font(.system(size: 24)) + .foregroundColor(.white.opacity(0.7)) + .padding(.leading, 10) + } + } + .padding(.horizontal, isLandscape ? 15 : 30) + + // Native Scrubber + VStack(spacing: 6) { + NativePlaybackSlider( + value: Binding( + get: { playbackTime.currentTime }, + set: { playbackTime.currentTime = $0; manager.seek(to: $0) } + ), + range: 0...max(manager.trackDuration, 1) + ) + + HStack { + Text(formatTime(playbackTime.currentTime)) + Spacer() + Text(formatTime(manager.trackDuration)) + } + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundColor(.white.opacity(0.5)) + } + .padding(.horizontal, isLandscape ? 15 : 30) + + // Playback Controls + HStack(spacing: isLandscape ? 40 : (isSmall ? 45 : 65)) { + Button(action: manager.prevTrack) { + Image(systemName: "backward.fill") + .font(.system(size: isSmall ? 28 : 36)) + } + Button(action: manager.playPause) { + Image(systemName: manager.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: isSmall ? 44 : 54)) + } + Button(action: manager.nextTrack) { + Image(systemName: "forward.fill") + .font(.system(size: isSmall ? 28 : 36)) + } + }.foregroundColor(.white) + + // Volume Bar + VolumeSliderView(value: Binding( + get: { manager.volume }, + set: { manager.volume = $0 } + )) + .padding(.horizontal, isLandscape ? 20 : 35) + + // Bottom Actions Bar + HStack(spacing: 0) { + Button(action: { withAnimation(.easeInOut(duration: 0.35)) { showLyrics.toggle() } }) { + Image(systemName: "quote.bubble") + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(showLyrics ? .white : .white.opacity(0.6)) + .frame(maxWidth: .infinity) + } + + AirPlayView().frame(maxWidth: .infinity, maxHeight: 35) + + Button(action: manager.toggleRepeat) { + Image(systemName: manager.repeatMode.iconName) + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(manager.repeatMode == .none ? .white.opacity(0.6) : .white) + .frame(maxWidth: .infinity) + } + + Button(action: manager.toggleShuffle) { + Image(systemName: "shuffle") + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(manager.shuffleOn ? .white : .white.opacity(0.6)) + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 10) + } + .padding(.top, isLandscape ? 10 : (isSmall ? 14 : 20)) + .padding(.bottom, isLandscape ? 20 : (isSmall ? 25 : 45)) + } + .transition(.opacity) + } + + func formatTime(_ t: TimeInterval) -> String { + guard t.isFinite && !t.isNaN else { return "00:00" } + let m = Int(t) / 60 + let s = Int(t) % 60 + return String(format: "%02d:%02d", m, s) + } +} + + +// MARK: - Экран информации о файле +struct FileInfoView: View { + @AppStorage("appTheme") private var appTheme: AppTheme = .system + let url: URL + let duration: TimeInterval + @Environment(\.dismiss) var dismiss + @State private var fileSize: String = "Вычисляется..." + @State private var showShareSheet = false + + var body: some View { + NavigationView { + List { + Section(header: Text("Основная информация")) { + InfoRow(title: "Имя файла", value: url.lastPathComponent) + InfoRow(title: "Формат", value: url.pathExtension.uppercased()) + InfoRow(title: "Размер", value: fileSize) + InfoRow(title: "Длительность", value: formatTime(duration)) + } + + Section(header: Text("Расположение файла")) { + Text(url.path).font(.caption2).foregroundColor(.gray) + } + + Section { + Button(action: { showShareSheet = true }) { + HStack { + Spacer() + Label("Поделиться файлом", systemImage: "square.and.arrow.up") + .foregroundColor(.red) + Spacer() + } + } + } + } + .navigationTitle("Свойства трека") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Готово") { dismiss() } + } + } + .onAppear(perform: calculateFileSize) + .sheet(isPresented: $showShareSheet) { + ActivityViewController(activityItems: [url]) + } + } + .preferredColorScheme(appTheme.colorScheme) + } + + private func calculateFileSize() { + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? Int64 { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB, .useKB] + formatter.countStyle = .file + fileSize = formatter.string(fromByteCount: size) + } else { fileSize = "Неизвестно" } + } + + private func formatTime(_ t: TimeInterval) -> String { + guard t.isFinite && !t.isNaN else { return "00:00" } + let m = Int(t) / 60 + let s = Int(t) % 60 + return String(format: "%02d:%02d", m, s) + } +} + +struct InfoRow: View { + let title: String + let value: String + var body: some View { + HStack(alignment: .top) { + Text(title).foregroundColor(.gray) + Spacer() + Text(value).multilineTextAlignment(.trailing) + } + } +} + +// MARK: - Системные обертки +struct BlurView: UIViewRepresentable { + var style: UIBlurEffect.Style + func makeUIView(context: Context) -> UIVisualEffectView { UIVisualEffectView(effect: UIBlurEffect(style: style)) } + func updateUIView(_ uiView: UIVisualEffectView, context: Context) {} +} + +struct ActivityViewController: UIViewControllerRepresentable { + var activityItems: [Any] + var applicationActivities: [UIActivity]? = nil + + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities) + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext) {} +} + +struct ImagePicker: UIViewControllerRepresentable { + var onImagePicked: (UIImage) -> Void + @Environment(\.presentationMode) private var presentationMode + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration() + config.filter = .images + let picker = PHPickerViewController(configuration: config) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: ImagePicker + init(_ parent: ImagePicker) { self.parent = parent } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + parent.presentationMode.wrappedValue.dismiss() + guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { return } + provider.loadObject(ofClass: UIImage.self) { image, _ in + if let uiImage = image as? UIImage { + DispatchQueue.main.async { self.parent.onImagePicked(uiImage) } + } + } + } + } +} + +struct AirPlayView: UIViewRepresentable { + func makeUIView(context: Context) -> AVRoutePickerView { + let view = AVRoutePickerView() + view.activeTintColor = .red + view.tintColor = .white + return view + } + + func updateUIView(_ uiView: AVRoutePickerView, context: Context) { + // Обновление не требуется + } +} diff --git a/music player/PhoneConnectivityManager.swift b/music player/PhoneConnectivityManager.swift new file mode 100644 index 0000000..e7610c0 --- /dev/null +++ b/music player/PhoneConnectivityManager.swift @@ -0,0 +1,117 @@ +import Foundation +import WatchConnectivity +import Combine + +// MARK: - Лёгкая модель трека для передачи на часы +struct WatchTrackInfo: Codable { + let id: String // используем имя файла как стабильный идентификатор + let title: String + let artist: String +} + +// MARK: - Связь iPhone <-> Apple Watch +// Добавь этот файл в таргет "music player" (основное приложение). +class PhoneConnectivityManager: NSObject, WCSessionDelegate, ObservableObject { + static let shared = PhoneConnectivityManager() + + private var cancellables = Set() + + private override init() { + super.init() + activateSession() + // Откладываем обращение к MusicManager.shared на следующий "тик" — + // иначе при первом запуске получится циклическая инициализация + // двух синглтонов друг через друга и приложение упадёт. + DispatchQueue.main.async { [weak self] in + self?.observeMusicManager() + } + } + + private func activateSession() { + guard WCSession.isSupported() else { return } + let session = WCSession.default + session.delegate = self + session.activate() + } + + // MARK: - Отправка состояния плеера на часы + + private func observeMusicManager() { + let manager = MusicManager.shared + Publishers.CombineLatest4( + manager.$tracks, + manager.$currentTrackIndex, + manager.$isPlaying, + manager.$currentTitle + ) + .debounce(for: .milliseconds(250), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + self?.pushStateToWatch() + } + .store(in: &cancellables) + } + + func pushStateToWatch() { + guard WCSession.default.activationState == .activated else { return } + let manager = MusicManager.shared + + let trackInfos = manager.tracks.map { + WatchTrackInfo(id: $0.url.lastPathComponent, title: $0.title, artist: $0.artist) + } + guard let tracksData = try? JSONEncoder().encode(trackInfos) else { return } + + let context: [String: Any] = [ + "tracks": tracksData, + "isPlaying": manager.isPlaying, + "currentTitle": manager.currentTitle, + "currentArtist": manager.currentArtist, + "currentId": manager.currentTrack?.url.lastPathComponent ?? "" + ] + + try? WCSession.default.updateApplicationContext(context) + } + + // MARK: - WCSessionDelegate + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + DispatchQueue.main.async { self.pushStateToWatch() } + } + + func sessionDidBecomeInactive(_ session: WCSession) {} + func sessionDidDeactivate(_ session: WCSession) { WCSession.default.activate() } + + // Команды, пришедшие пока приложение на телефоне активно + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + handleCommand(message) + } + + // Команды, пришедшие "в очереди" (если телефон был недоступен) + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + handleCommand(userInfo) + } + + // MARK: - Обработка команд с часов + + private func handleCommand(_ command: [String: Any]) { + guard let action = command["action"] as? String else { return } + DispatchQueue.main.async { + let manager = MusicManager.shared + switch action { + case "playPause": + manager.playPause() + case "next": + manager.nextTrack() + case "previous": + manager.prevTrack() + case "playTrack": + if let id = command["id"] as? String, + let index = manager.currentList.firstIndex(where: { $0.url.lastPathComponent == id }) { + manager.playTrack(at: index) + } + default: + break + } + self.pushStateToWatch() + } + } +} diff --git a/music player/Preview Content/.DS_Store b/music player/Preview Content/.DS_Store new file mode 100644 index 0000000..d04eacd Binary files /dev/null and b/music player/Preview Content/.DS_Store differ diff --git a/music player/Preview Content/Preview Assets.xcassets/Contents.json b/music player/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/music player/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/music player/music player.entitlements b/music player/music player.entitlements new file mode 100644 index 0000000..ee95ab7 --- /dev/null +++ b/music player/music player.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/music player/music_playerApp.swift b/music player/music_playerApp.swift new file mode 100644 index 0000000..03d1422 --- /dev/null +++ b/music player/music_playerApp.swift @@ -0,0 +1,17 @@ +// +// music_playerApp.swift +// music player +// +// Created by aswer on 23.06.2026. +// + +import SwiftUI + +@main +struct music_playerApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/music playerTests/music_playerTests.swift b/music playerTests/music_playerTests.swift new file mode 100644 index 0000000..5ed23e4 --- /dev/null +++ b/music playerTests/music_playerTests.swift @@ -0,0 +1,36 @@ +// +// music_playerTests.swift +// music playerTests +// +// Created by aswer on 23.06.2026. +// + +import XCTest +@testable import music_player + +final class music_playerTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/music playerUITests/music_playerUITests.swift b/music playerUITests/music_playerUITests.swift new file mode 100644 index 0000000..b40ced1 --- /dev/null +++ b/music playerUITests/music_playerUITests.swift @@ -0,0 +1,41 @@ +// +// music_playerUITests.swift +// music playerUITests +// +// Created by aswer on 23.06.2026. +// + +import XCTest + +final class music_playerUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +} diff --git a/music playerUITests/music_playerUITestsLaunchTests.swift b/music playerUITests/music_playerUITestsLaunchTests.swift new file mode 100644 index 0000000..323a701 --- /dev/null +++ b/music playerUITests/music_playerUITestsLaunchTests.swift @@ -0,0 +1,32 @@ +// +// music_playerUITestsLaunchTests.swift +// music playerUITests +// +// Created by aswer on 23.06.2026. +// + +import XCTest + +final class music_playerUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/music playerWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json b/music playerWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/music playerWatch Watch App/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/music playerWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/music playerWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..49c81cd --- /dev/null +++ b/music playerWatch Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/music playerWatch Watch App/Assets.xcassets/Contents.json b/music playerWatch Watch App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/music playerWatch Watch App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/music playerWatch Watch App/ContentView.swift b/music playerWatch Watch App/ContentView.swift new file mode 100644 index 0000000..1229ca9 --- /dev/null +++ b/music playerWatch Watch App/ContentView.swift @@ -0,0 +1,62 @@ +// +// ContentView.swift +// music playerWatch Watch App +// +// Created by aswer on 30.06.2026. +// + +import SwiftUI + +// MARK: - Главный экран часов +// Замени этим содержимым ContentView.swift, который Xcode сгенерировал +// в таргете часов (структура должна остаться по имени ContentView, +// чтобы App.swift на часах продолжил работать без изменений). +struct ContentView: View { + @StateObject private var connectivity = WatchConnectivityManager.shared + + var body: some View { + NavigationView { + List { + if !connectivity.currentTitle.isEmpty { + NavigationLink(destination: NowPlayingView()) { + HStack { + Image(systemName: connectivity.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .foregroundColor(.green) + VStack(alignment: .leading) { + Text(connectivity.currentTitle) + .font(.headline) + .lineLimit(1) + Text(connectivity.currentArtist) + .font(.caption) + .foregroundColor(.gray) + .lineLimit(1) + } + } + } + } + + if connectivity.tracks.isEmpty { + Text("Открой приложение на iPhone, чтобы список треков появился здесь") + .font(.caption2) + .foregroundColor(.gray) + } else { + ForEach(connectivity.tracks) { track in + Button(action: { connectivity.playTrack(id: track.id) }) { + VStack(alignment: .leading) { + Text(track.title) + .font(.body) + .foregroundColor(track.id == connectivity.currentId ? .green : .primary) + .lineLimit(1) + Text(track.artist) + .font(.caption2) + .foregroundColor(.gray) + .lineLimit(1) + } + } + } + } + } + .navigationTitle("Музыка") + } + } +} diff --git a/music playerWatch Watch App/NowPlayingView.swift b/music playerWatch Watch App/NowPlayingView.swift new file mode 100644 index 0000000..3976101 --- /dev/null +++ b/music playerWatch Watch App/NowPlayingView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +// MARK: - Экран "Сейчас играет" на часах +struct NowPlayingView: View { + @StateObject private var connectivity = WatchConnectivityManager.shared + + var body: some View { + VStack(spacing: 10) { + Text(connectivity.currentTitle) + .font(.headline) + .multilineTextAlignment(.center) + .lineLimit(2) + + Text(connectivity.currentArtist) + .font(.subheadline) + .foregroundColor(.gray) + .lineLimit(1) + + HStack(spacing: 22) { + Button(action: connectivity.previous) { + Image(systemName: "backward.fill") + .font(.title2) + } + + Button(action: connectivity.playPause) { + Image(systemName: connectivity.isPlaying ? "pause.fill" : "play.fill") + .font(.largeTitle) + } + + Button(action: connectivity.next) { + Image(systemName: "forward.fill") + .font(.title2) + } + } + .buttonStyle(.plain) + .padding(.top, 6) + } + .padding() + .navigationTitle("Сейчас играет") + } +} diff --git a/music playerWatch Watch App/Preview Content/Preview Assets.xcassets/Contents.json b/music playerWatch Watch App/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/music playerWatch Watch App/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/music playerWatch Watch App/WatchConnectivityManager.swift b/music playerWatch Watch App/WatchConnectivityManager.swift new file mode 100644 index 0000000..8e412ca --- /dev/null +++ b/music playerWatch Watch App/WatchConnectivityManager.swift @@ -0,0 +1,73 @@ +import Foundation +import WatchConnectivity +import Combine + +// MARK: - Та же лёгкая модель трека, что и на телефоне +struct WatchTrackInfo: Codable, Identifiable { + let id: String + let title: String + let artist: String +} + +// MARK: - Связь Apple Watch <-> iPhone +// Добавь этот файл в таргет часов (например "music playerWatch"). +class WatchConnectivityManager: NSObject, WCSessionDelegate, ObservableObject { + static let shared = WatchConnectivityManager() + + @Published var tracks: [WatchTrackInfo] = [] + @Published var isPlaying: Bool = false + @Published var currentTitle: String = "" + @Published var currentArtist: String = "" + @Published var currentId: String = "" + + private override init() { + super.init() + guard WCSession.isSupported() else { return } + let session = WCSession.default + session.delegate = self + session.activate() + } + + // MARK: - WCSessionDelegate + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + DispatchQueue.main.async { + self.apply(applicationContext) + } + } + + private func apply(_ context: [String: Any]) { + if let data = context["tracks"] as? Data, + let decoded = try? JSONDecoder().decode([WatchTrackInfo].self, from: data) { + self.tracks = decoded + } + self.isPlaying = context["isPlaying"] as? Bool ?? false + self.currentTitle = context["currentTitle"] as? String ?? "" + self.currentArtist = context["currentArtist"] as? String ?? "" + self.currentId = context["currentId"] as? String ?? "" + } + + // MARK: - Отправка команд на телефон + + private func send(action: String, extra: [String: Any] = [:]) { + guard WCSession.isSupported() else { return } + var payload = extra + payload["action"] = action + + let session = WCSession.default + if session.isReachable { + session.sendMessage(payload, replyHandler: nil) { _ in + session.transferUserInfo(payload) + } + } else { + session.transferUserInfo(payload) + } + } + + func playPause() { send(action: "playPause") } + func next() { send(action: "next") } + func previous() { send(action: "previous") } + func playTrack(id: String) { send(action: "playTrack", extra: ["id": id]) } +} diff --git a/music playerWatch Watch App/music_playerWatchApp.swift b/music playerWatch Watch App/music_playerWatchApp.swift new file mode 100644 index 0000000..220bb2c --- /dev/null +++ b/music playerWatch Watch App/music_playerWatchApp.swift @@ -0,0 +1,17 @@ +// +// music_playerWatchApp.swift +// music playerWatch Watch App +// +// Created by aswer on 30.06.2026. +// + +import SwiftUI + +@main +struct music_playerWatch_Watch_AppApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/music playerWatch Watch AppTests/music_playerWatch_Watch_AppTests.swift b/music playerWatch Watch AppTests/music_playerWatch_Watch_AppTests.swift new file mode 100644 index 0000000..4812558 --- /dev/null +++ b/music playerWatch Watch AppTests/music_playerWatch_Watch_AppTests.swift @@ -0,0 +1,36 @@ +// +// music_playerWatch_Watch_AppTests.swift +// music playerWatch Watch AppTests +// +// Created by aswer on 30.06.2026. +// + +import XCTest +@testable import music_playerWatch_Watch_App + +final class music_playerWatch_Watch_AppTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Tests marked async will run the test method on an arbitrary thread managed by the Swift runtime. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/music playerWatch Watch AppUITests/music_playerWatch_Watch_AppUITests.swift b/music playerWatch Watch AppUITests/music_playerWatch_Watch_AppUITests.swift new file mode 100644 index 0000000..ec6a044 --- /dev/null +++ b/music playerWatch Watch AppUITests/music_playerWatch_Watch_AppUITests.swift @@ -0,0 +1,41 @@ +// +// music_playerWatch_Watch_AppUITests.swift +// music playerWatch Watch AppUITests +// +// Created by aswer on 30.06.2026. +// + +import XCTest + +final class music_playerWatch_Watch_AppUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + func testLaunchPerformance() throws { + if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } + } +} diff --git a/music playerWatch Watch AppUITests/music_playerWatch_Watch_AppUITestsLaunchTests.swift b/music playerWatch Watch AppUITests/music_playerWatch_Watch_AppUITestsLaunchTests.swift new file mode 100644 index 0000000..95cab07 --- /dev/null +++ b/music playerWatch Watch AppUITests/music_playerWatch_Watch_AppUITestsLaunchTests.swift @@ -0,0 +1,32 @@ +// +// music_playerWatch_Watch_AppUITestsLaunchTests.swift +// music playerWatch Watch AppUITests +// +// Created by aswer on 30.06.2026. +// + +import XCTest + +final class music_playerWatch_Watch_AppUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/music-player-Info.plist b/music-player-Info.plist new file mode 100644 index 0000000..f753731 --- /dev/null +++ b/music-player-Info.plist @@ -0,0 +1,10 @@ + + + + + UIBackgroundModes + + audio + + +