├── .gitignore ├── .swiftformat ├── .swiftlint.yml ├── AppleMusicStylePlayer.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── swiftpm │ └── Package.resolved ├── AppleMusicStylePlayer ├── AppleMusicStylePlayerApp.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ └── img_home.imageset │ │ ├── Contents.json │ │ └── img_home.pdf ├── Helpers │ ├── ClosedRange+Extensions.swift │ ├── Collection+Extensions.swift │ ├── Time.swift │ └── UIApplication+Extensions.swift ├── MediaLibrary │ ├── Media.swift │ ├── MediaLibrary.swift │ ├── MediaList.swift │ └── MockGTA5Radiostations.swift ├── MediaList │ ├── MediaListView.swift │ └── PlayListController.swift ├── NowPlaying │ ├── CompactNowPlaying.swift │ ├── ExpandableNowPlaying.swift │ ├── NowPlayingBackground.swift │ ├── NowPlayingExpandTracking.swift │ ├── PlayerControls │ │ ├── ForwardLabel.swift │ │ ├── PlayerButtonLabel.swift │ │ ├── PlayerButtons.swift │ │ ├── PlayerControls.swift │ │ ├── PreviewBackground.swift │ │ ├── TimingIndicator.swift │ │ └── VolumeSlider.swift │ └── RegularNowPlaying.swift ├── NowPlayingController │ └── NowPlayingController.swift ├── OverlaidRootView.swift ├── Player │ └── Player.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── RootView.swift └── UI │ ├── Components │ ├── Background │ │ ├── ColorPoint.swift │ │ ├── ColorfulBackground.swift │ │ ├── ColorfulBackgroundModel.swift │ │ ├── MulticolorGradient.swift │ │ ├── MulticolorGradientShader.metal │ │ └── Uniforms.swift │ ├── BlurView.swift │ ├── ElasticSlider.swift │ ├── MarqueeText.swift │ └── PlayerButton.swift │ ├── Consts │ ├── AppFont.swift │ ├── Palette.swift │ └── ViewConst.swift │ ├── CustomTabBar │ ├── CustomTabView.swift │ └── TabBarItem.swift │ ├── DominantColors.swift │ ├── Extensions │ ├── UIColor+Extensions.swift │ ├── UIEdgeInsets+Extensions.swift │ ├── UIImage+Extensions.swift │ └── UIScreen+Extensions.swift │ ├── Modifiers │ ├── Hidden.swift │ ├── MeasureSizeModifier.swift │ └── PressGesture.swift │ ├── PanGesture.swift │ └── UniversalOverlay.swift ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | .DS_Store 5 | 6 | ## User settings 7 | xcuserdata/ 8 | 9 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 10 | *.xcscmblueprint 11 | *.xccheckout 12 | 13 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 14 | build/ 15 | DerivedData/ 16 | *.moved-aside 17 | *.pbxuser 18 | !default.pbxuser 19 | *.mode1v3 20 | !default.mode1v3 21 | *.mode2v3 22 | !default.mode2v3 23 | *.perspectivev3 24 | !default.perspectivev3 25 | 26 | ## Obj-C/Swift specific 27 | *.hmap 28 | 29 | ## App packaging 30 | *.ipa 31 | *.dSYM.zip 32 | *.dSYM 33 | 34 | ## Playgrounds 35 | timeline.xctimeline 36 | playground.xcworkspace 37 | 38 | # Swift Package Manager 39 | # 40 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 41 | # Packages/ 42 | # Package.pins 43 | # Package.resolved 44 | # *.xcodeproj 45 | # 46 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 47 | # hence it is not needed unless you have added a package configuration file to your project 48 | # .swiftpm 49 | 50 | .build/ 51 | 52 | # CocoaPods 53 | # 54 | # We recommend against adding the Pods directory to your .gitignore. However 55 | # you should judge for yourself, the pros and cons are mentioned at: 56 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 57 | # 58 | # Pods/ 59 | # 60 | # Add this line if you want to avoid checking in source code from the Xcode workspace 61 | # *.xcworkspace 62 | 63 | # Carthage 64 | # 65 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 66 | # Carthage/Checkouts 67 | 68 | Carthage/Build/ 69 | 70 | # Accio dependency management 71 | Dependencies/ 72 | .accio/ 73 | 74 | # fastlane 75 | # 76 | # It is recommended to not store the screenshots in the git repo. 77 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 78 | # For more information about the recommended setup visit: 79 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 80 | 81 | fastlane/report.xml 82 | fastlane/Preview.html 83 | fastlane/screenshots/**/*.png 84 | fastlane/test_output 85 | 86 | # Code Injection 87 | # 88 | # After new code Injection tools there's a generated folder /iOSInjectionProject 89 | # https://github.com/johnno1962/injectionforxcode 90 | 91 | iOSInjectionProject/ 92 | -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | --disable trailingCommas 2 | --ifdef no-indent 3 | --swiftversion 5 -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | identifier_name: 2 | min_length: 1 3 | 4 | cyclomatic_complexity: 5 | ignores_case_statements: true 6 | 7 | disabled_rules: 8 | - trailing_whitespace 9 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | ADA654C32CFB1E2300EDEA39 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = ADA654C22CFB1E2300EDEA39 /* Kingfisher */; }; 11 | /* End PBXBuildFile section */ 12 | 13 | /* Begin PBXFileReference section */ 14 | AD1EE1852CEA1DBE007DBDE3 /* AppleMusicStylePlayer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppleMusicStylePlayer.app; sourceTree = BUILT_PRODUCTS_DIR; }; 15 | /* End PBXFileReference section */ 16 | 17 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 18 | AD1EE1872CEA1DBE007DBDE3 /* AppleMusicStylePlayer */ = { 19 | isa = PBXFileSystemSynchronizedRootGroup; 20 | path = AppleMusicStylePlayer; 21 | sourceTree = ""; 22 | }; 23 | /* End PBXFileSystemSynchronizedRootGroup section */ 24 | 25 | /* Begin PBXFrameworksBuildPhase section */ 26 | AD1EE1822CEA1DBE007DBDE3 /* Frameworks */ = { 27 | isa = PBXFrameworksBuildPhase; 28 | buildActionMask = 2147483647; 29 | files = ( 30 | ADA654C32CFB1E2300EDEA39 /* Kingfisher in Frameworks */, 31 | ); 32 | runOnlyForDeploymentPostprocessing = 0; 33 | }; 34 | /* End PBXFrameworksBuildPhase section */ 35 | 36 | /* Begin PBXGroup section */ 37 | AD1EE17C2CEA1DBE007DBDE3 = { 38 | isa = PBXGroup; 39 | children = ( 40 | AD1EE1872CEA1DBE007DBDE3 /* AppleMusicStylePlayer */, 41 | AD1EE1862CEA1DBE007DBDE3 /* Products */, 42 | ); 43 | sourceTree = ""; 44 | }; 45 | AD1EE1862CEA1DBE007DBDE3 /* Products */ = { 46 | isa = PBXGroup; 47 | children = ( 48 | AD1EE1852CEA1DBE007DBDE3 /* AppleMusicStylePlayer.app */, 49 | ); 50 | name = Products; 51 | sourceTree = ""; 52 | }; 53 | /* End PBXGroup section */ 54 | 55 | /* Begin PBXNativeTarget section */ 56 | AD1EE1842CEA1DBE007DBDE3 /* AppleMusicStylePlayer */ = { 57 | isa = PBXNativeTarget; 58 | buildConfigurationList = AD1EE1932CEA1DBF007DBDE3 /* Build configuration list for PBXNativeTarget "AppleMusicStylePlayer" */; 59 | buildPhases = ( 60 | AD1EE1812CEA1DBE007DBDE3 /* Sources */, 61 | AD1EE1822CEA1DBE007DBDE3 /* Frameworks */, 62 | AD1EE1832CEA1DBE007DBDE3 /* Resources */, 63 | AD1D39F02CF6E96200B7C883 /* Run Swiftlint */, 64 | ); 65 | buildRules = ( 66 | ); 67 | dependencies = ( 68 | ); 69 | fileSystemSynchronizedGroups = ( 70 | AD1EE1872CEA1DBE007DBDE3 /* AppleMusicStylePlayer */, 71 | ); 72 | name = AppleMusicStylePlayer; 73 | packageProductDependencies = ( 74 | ADA654C22CFB1E2300EDEA39 /* Kingfisher */, 75 | ); 76 | productName = AppleMusicStylePlayer; 77 | productReference = AD1EE1852CEA1DBE007DBDE3 /* AppleMusicStylePlayer.app */; 78 | productType = "com.apple.product-type.application"; 79 | }; 80 | /* End PBXNativeTarget section */ 81 | 82 | /* Begin PBXProject section */ 83 | AD1EE17D2CEA1DBE007DBDE3 /* Project object */ = { 84 | isa = PBXProject; 85 | attributes = { 86 | BuildIndependentTargetsInParallel = 1; 87 | LastSwiftUpdateCheck = 1600; 88 | LastUpgradeCheck = 1600; 89 | TargetAttributes = { 90 | AD1EE1842CEA1DBE007DBDE3 = { 91 | CreatedOnToolsVersion = 16.0; 92 | }; 93 | }; 94 | }; 95 | buildConfigurationList = AD1EE1802CEA1DBE007DBDE3 /* Build configuration list for PBXProject "AppleMusicStylePlayer" */; 96 | developmentRegion = en; 97 | hasScannedForEncodings = 0; 98 | knownRegions = ( 99 | en, 100 | Base, 101 | ); 102 | mainGroup = AD1EE17C2CEA1DBE007DBDE3; 103 | minimizedProjectReferenceProxies = 1; 104 | packageReferences = ( 105 | ADA654C12CFB1E2300EDEA39 /* XCRemoteSwiftPackageReference "Kingfisher" */, 106 | ); 107 | preferredProjectObjectVersion = 77; 108 | productRefGroup = AD1EE1862CEA1DBE007DBDE3 /* Products */; 109 | projectDirPath = ""; 110 | projectRoot = ""; 111 | targets = ( 112 | AD1EE1842CEA1DBE007DBDE3 /* AppleMusicStylePlayer */, 113 | ); 114 | }; 115 | /* End PBXProject section */ 116 | 117 | /* Begin PBXResourcesBuildPhase section */ 118 | AD1EE1832CEA1DBE007DBDE3 /* Resources */ = { 119 | isa = PBXResourcesBuildPhase; 120 | buildActionMask = 2147483647; 121 | files = ( 122 | ); 123 | runOnlyForDeploymentPostprocessing = 0; 124 | }; 125 | /* End PBXResourcesBuildPhase section */ 126 | 127 | /* Begin PBXShellScriptBuildPhase section */ 128 | AD1D39F02CF6E96200B7C883 /* Run Swiftlint */ = { 129 | isa = PBXShellScriptBuildPhase; 130 | alwaysOutOfDate = 1; 131 | buildActionMask = 2147483647; 132 | files = ( 133 | ); 134 | inputFileListPaths = ( 135 | ); 136 | inputPaths = ( 137 | ); 138 | name = "Run Swiftlint"; 139 | outputFileListPaths = ( 140 | ); 141 | outputPaths = ( 142 | ); 143 | runOnlyForDeploymentPostprocessing = 0; 144 | shellPath = /bin/sh; 145 | shellScript = "# Run SwiftLint\nSTART_DATE=$(date +\"%s\")\n\nPATH=${PATH}:/opt/homebrew/bin:/opt/local/bin:/usr/local/bin\n\nSWIFT_LINT=`which swiftlint`\n\n# Run SwiftLint for given filename\nrun_swiftlint() {\n ${SWIFT_LINT}\n local filename=\"${1}\"\n if [[ \"${filename##*.}\" == \"swift\" ]]; then\n # ${SWIFT_LINT} --fix \"${filename}\"\n ${SWIFT_LINT} lint \"${filename}\"\n fi\n}\n\nif [ -x \"$(command -v ${SWIFT_LINT})\" ]\nthen\n echo \"SwiftLint version: $(${SWIFT_LINT} version)\"\n run_swiftlint\n git config --global diff.submodule diff\n\n # Run for both staged and unstaged files in main repo\n git diff --name-only | while read filename; do run_swiftlint \"${filename}\"; done\n #git diff --cached --name-only | while read filename; do run_swiftlint \"${filename}\"; done\n\n # Run for all files in submodules\n git diff | grep '^diff --git' | sed -e 's/diff --git a\\/\\(.*\\) b\\/.*/\\1/' | grep '\\.swift$' | while read filename; do run_swiftlint \"${filename}\"; done\nelse\n echo \"${SWIFT_LINT} is not installed.\"\n exit 0\nfi\n\nEND_DATE=$(date +\"%s\")\n\nDIFF=$(($END_DATE - $START_DATE))\necho \"SwiftLint took $(($DIFF / 60)) minutes and $(($DIFF % 60)) seconds to complete.\"\n"; 146 | }; 147 | /* End PBXShellScriptBuildPhase section */ 148 | 149 | /* Begin PBXSourcesBuildPhase section */ 150 | AD1EE1812CEA1DBE007DBDE3 /* Sources */ = { 151 | isa = PBXSourcesBuildPhase; 152 | buildActionMask = 2147483647; 153 | files = ( 154 | ); 155 | runOnlyForDeploymentPostprocessing = 0; 156 | }; 157 | /* End PBXSourcesBuildPhase section */ 158 | 159 | /* Begin XCBuildConfiguration section */ 160 | AD1EE1912CEA1DBF007DBDE3 /* Debug */ = { 161 | isa = XCBuildConfiguration; 162 | buildSettings = { 163 | ALWAYS_SEARCH_USER_PATHS = NO; 164 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 165 | CLANG_ANALYZER_NONNULL = YES; 166 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 167 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 168 | CLANG_ENABLE_MODULES = YES; 169 | CLANG_ENABLE_OBJC_ARC = YES; 170 | CLANG_ENABLE_OBJC_WEAK = YES; 171 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 172 | CLANG_WARN_BOOL_CONVERSION = YES; 173 | CLANG_WARN_COMMA = YES; 174 | CLANG_WARN_CONSTANT_CONVERSION = YES; 175 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 176 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 177 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 178 | CLANG_WARN_EMPTY_BODY = YES; 179 | CLANG_WARN_ENUM_CONVERSION = YES; 180 | CLANG_WARN_INFINITE_RECURSION = YES; 181 | CLANG_WARN_INT_CONVERSION = YES; 182 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 183 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 184 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 185 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 186 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 187 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 188 | CLANG_WARN_STRICT_PROTOTYPES = YES; 189 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 190 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 191 | CLANG_WARN_UNREACHABLE_CODE = YES; 192 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 193 | COPY_PHASE_STRIP = NO; 194 | DEBUG_INFORMATION_FORMAT = dwarf; 195 | ENABLE_STRICT_OBJC_MSGSEND = YES; 196 | ENABLE_TESTABILITY = YES; 197 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 198 | GCC_C_LANGUAGE_STANDARD = gnu17; 199 | GCC_DYNAMIC_NO_PIC = NO; 200 | GCC_NO_COMMON_BLOCKS = YES; 201 | GCC_OPTIMIZATION_LEVEL = 0; 202 | GCC_PREPROCESSOR_DEFINITIONS = ( 203 | "DEBUG=1", 204 | "$(inherited)", 205 | ); 206 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 207 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 208 | GCC_WARN_UNDECLARED_SELECTOR = YES; 209 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 210 | GCC_WARN_UNUSED_FUNCTION = YES; 211 | GCC_WARN_UNUSED_VARIABLE = YES; 212 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 213 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 214 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 215 | MTL_FAST_MATH = YES; 216 | ONLY_ACTIVE_ARCH = YES; 217 | SDKROOT = iphoneos; 218 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 219 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 220 | SWIFT_VERSION = 6.0; 221 | }; 222 | name = Debug; 223 | }; 224 | AD1EE1922CEA1DBF007DBDE3 /* Release */ = { 225 | isa = XCBuildConfiguration; 226 | buildSettings = { 227 | ALWAYS_SEARCH_USER_PATHS = NO; 228 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 229 | CLANG_ANALYZER_NONNULL = YES; 230 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 231 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 232 | CLANG_ENABLE_MODULES = YES; 233 | CLANG_ENABLE_OBJC_ARC = YES; 234 | CLANG_ENABLE_OBJC_WEAK = YES; 235 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 236 | CLANG_WARN_BOOL_CONVERSION = YES; 237 | CLANG_WARN_COMMA = YES; 238 | CLANG_WARN_CONSTANT_CONVERSION = YES; 239 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 240 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 241 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 242 | CLANG_WARN_EMPTY_BODY = YES; 243 | CLANG_WARN_ENUM_CONVERSION = YES; 244 | CLANG_WARN_INFINITE_RECURSION = YES; 245 | CLANG_WARN_INT_CONVERSION = YES; 246 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 247 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 248 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 249 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 250 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 251 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 252 | CLANG_WARN_STRICT_PROTOTYPES = YES; 253 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 254 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 255 | CLANG_WARN_UNREACHABLE_CODE = YES; 256 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 257 | COPY_PHASE_STRIP = NO; 258 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 259 | ENABLE_NS_ASSERTIONS = NO; 260 | ENABLE_STRICT_OBJC_MSGSEND = YES; 261 | ENABLE_USER_SCRIPT_SANDBOXING = NO; 262 | GCC_C_LANGUAGE_STANDARD = gnu17; 263 | GCC_NO_COMMON_BLOCKS = YES; 264 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 265 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 266 | GCC_WARN_UNDECLARED_SELECTOR = YES; 267 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 268 | GCC_WARN_UNUSED_FUNCTION = YES; 269 | GCC_WARN_UNUSED_VARIABLE = YES; 270 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 271 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 272 | MTL_ENABLE_DEBUG_INFO = NO; 273 | MTL_FAST_MATH = YES; 274 | SDKROOT = iphoneos; 275 | SWIFT_COMPILATION_MODE = wholemodule; 276 | SWIFT_VERSION = 6.0; 277 | VALIDATE_PRODUCT = YES; 278 | }; 279 | name = Release; 280 | }; 281 | AD1EE1942CEA1DBF007DBDE3 /* Debug */ = { 282 | isa = XCBuildConfiguration; 283 | buildSettings = { 284 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 285 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 286 | CODE_SIGN_STYLE = Automatic; 287 | CURRENT_PROJECT_VERSION = 1; 288 | DEVELOPMENT_ASSET_PATHS = "\"AppleMusicStylePlayer/Preview Content\""; 289 | DEVELOPMENT_TEAM = ""; 290 | ENABLE_PREVIEWS = YES; 291 | GENERATE_INFOPLIST_FILE = YES; 292 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 293 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 294 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 295 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 296 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 297 | LD_RUNPATH_SEARCH_PATHS = ( 298 | "$(inherited)", 299 | "@executable_path/Frameworks", 300 | ); 301 | MARKETING_VERSION = 1.0; 302 | PRODUCT_BUNDLE_IDENTIFIER = f728743.AppleMusicStylePlayer; 303 | PRODUCT_NAME = "$(TARGET_NAME)"; 304 | SWIFT_EMIT_LOC_STRINGS = YES; 305 | SWIFT_VERSION = 6.0; 306 | TARGETED_DEVICE_FAMILY = "1,2"; 307 | }; 308 | name = Debug; 309 | }; 310 | AD1EE1952CEA1DBF007DBDE3 /* Release */ = { 311 | isa = XCBuildConfiguration; 312 | buildSettings = { 313 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 314 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 315 | CODE_SIGN_STYLE = Automatic; 316 | CURRENT_PROJECT_VERSION = 1; 317 | DEVELOPMENT_ASSET_PATHS = "\"AppleMusicStylePlayer/Preview Content\""; 318 | DEVELOPMENT_TEAM = ""; 319 | ENABLE_PREVIEWS = YES; 320 | GENERATE_INFOPLIST_FILE = YES; 321 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 322 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 323 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 324 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 325 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 326 | LD_RUNPATH_SEARCH_PATHS = ( 327 | "$(inherited)", 328 | "@executable_path/Frameworks", 329 | ); 330 | MARKETING_VERSION = 1.0; 331 | PRODUCT_BUNDLE_IDENTIFIER = f728743.AppleMusicStylePlayer; 332 | PRODUCT_NAME = "$(TARGET_NAME)"; 333 | SWIFT_EMIT_LOC_STRINGS = YES; 334 | SWIFT_VERSION = 6.0; 335 | TARGETED_DEVICE_FAMILY = "1,2"; 336 | }; 337 | name = Release; 338 | }; 339 | /* End XCBuildConfiguration section */ 340 | 341 | /* Begin XCConfigurationList section */ 342 | AD1EE1802CEA1DBE007DBDE3 /* Build configuration list for PBXProject "AppleMusicStylePlayer" */ = { 343 | isa = XCConfigurationList; 344 | buildConfigurations = ( 345 | AD1EE1912CEA1DBF007DBDE3 /* Debug */, 346 | AD1EE1922CEA1DBF007DBDE3 /* Release */, 347 | ); 348 | defaultConfigurationIsVisible = 0; 349 | defaultConfigurationName = Release; 350 | }; 351 | AD1EE1932CEA1DBF007DBDE3 /* Build configuration list for PBXNativeTarget "AppleMusicStylePlayer" */ = { 352 | isa = XCConfigurationList; 353 | buildConfigurations = ( 354 | AD1EE1942CEA1DBF007DBDE3 /* Debug */, 355 | AD1EE1952CEA1DBF007DBDE3 /* Release */, 356 | ); 357 | defaultConfigurationIsVisible = 0; 358 | defaultConfigurationName = Release; 359 | }; 360 | /* End XCConfigurationList section */ 361 | 362 | /* Begin XCRemoteSwiftPackageReference section */ 363 | ADA654C12CFB1E2300EDEA39 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { 364 | isa = XCRemoteSwiftPackageReference; 365 | repositoryURL = "https://github.com/onevcat/Kingfisher.git"; 366 | requirement = { 367 | kind = upToNextMajorVersion; 368 | minimumVersion = 8.1.1; 369 | }; 370 | }; 371 | /* End XCRemoteSwiftPackageReference section */ 372 | 373 | /* Begin XCSwiftPackageProductDependency section */ 374 | ADA654C22CFB1E2300EDEA39 /* Kingfisher */ = { 375 | isa = XCSwiftPackageProductDependency; 376 | package = ADA654C12CFB1E2300EDEA39 /* XCRemoteSwiftPackageReference "Kingfisher" */; 377 | productName = Kingfisher; 378 | }; 379 | /* End XCSwiftPackageProductDependency section */ 380 | }; 381 | rootObject = AD1EE17D2CEA1DBE007DBDE3 /* Project object */; 382 | } 383 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "917a2b512148612347b307b5f8d624e74df48af36fa322c80901a86fc0e1317f", 3 | "pins" : [ 4 | { 5 | "identity" : "kingfisher", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/onevcat/Kingfisher.git", 8 | "state" : { 9 | "revision" : "fa3609d1975791c0a6e6f75a56dc20a957967769", 10 | "version" : "8.1.4" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/AppleMusicStylePlayerApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppleMusicStylePlayerApp.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 17.11.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct AppleMusicStylePlayerApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | OverlayableRootView { 15 | OverlaidRootView() 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/Assets.xcassets/img_home.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "img_home.pdf", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/Assets.xcassets/img_home.imageset/img_home.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/f728743/AppleMusicStylePlayer/61d6bd339400a18d36cb92b334d72cb564de8148/AppleMusicStylePlayer/Assets.xcassets/img_home.imageset/img_home.pdf -------------------------------------------------------------------------------- /AppleMusicStylePlayer/Helpers/ClosedRange+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClosedRange+Extensions.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 22.12.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | extension ClosedRange where Bound: AdditiveArithmetic { 11 | var distance: Bound { 12 | upperBound - lowerBound 13 | } 14 | } 15 | 16 | extension Comparable { 17 | func clamped(to limits: ClosedRange) -> Self { 18 | return min(max(self, limits.lowerBound), limits.upperBound) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/Helpers/Collection+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+Extensions.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 27.11.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Collection { 11 | subscript(safe index: Index) -> Element? { 12 | return indices.contains(index) ? self[index] : nil 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/Helpers/Time.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Time.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 20.12.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | public func delay(_ delay: Double, closure: @escaping @Sendable () -> Void) { 11 | let when = DispatchTime.now() + delay 12 | DispatchQueue.main.asyncAfter(deadline: when, execute: closure) 13 | } 14 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/Helpers/UIApplication+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIApplication+Extensions.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 20.11.2024. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIApplication { 11 | static var keyWindow: UIWindow? { 12 | (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.keyWindow 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/MediaLibrary/Media.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Media.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 27.11.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Media { 11 | let artwork: URL? 12 | let title: String 13 | let subtitle: String? 14 | let online: Bool 15 | } 16 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/MediaLibrary/MediaLibrary.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaLibrary.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 27.11.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | final class MediaLibrary { 11 | var list: [MediaList] 12 | 13 | init() { 14 | list = [MockGTA5Radio().mediaList] 15 | } 16 | 17 | var isEmpty: Bool { 18 | !list.contains { !$0.items.isEmpty } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/MediaLibrary/MediaList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaList.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 27.11.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | struct MediaList { 11 | let artwork: URL? 12 | let title: String 13 | let subtitle: String? 14 | let items: [Media] 15 | } 16 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/MediaLibrary/MockGTA5Radiostations.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockGTA5Radiostations.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 27.11.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | struct MockGTA5Radio { 11 | private let stations = gta5stations 12 | } 13 | 14 | extension MockGTA5Radio { 15 | var mediaList: MediaList { 16 | MediaList( 17 | artwork: stationGroupImageUrl(), 18 | title: "GTA V Radio", 19 | subtitle: nil, 20 | items: stations.map { 21 | Media( 22 | artwork: stationImageUrl(String($0.logo.split(separator: ".")[0])), 23 | title: $0.title, 24 | subtitle: $0.genre, 25 | online: false 26 | ) 27 | } 28 | ) 29 | } 30 | } 31 | 32 | private let gta5BaseUrl = "https://raw.githubusercontent.com/tmp-acc/GTA-V-Radio-Stations/master" 33 | private func stationImageUrl(_ satrion: String) -> URL? { 34 | URL(string: "\(gta5BaseUrl)/\(satrion)/\(satrion).png") 35 | } 36 | 37 | private func stationGroupImageUrl() -> URL? { 38 | URL(string: "\(gta5BaseUrl)/gta_v.png") 39 | } 40 | 41 | private struct GTARadioStation { 42 | let title: String 43 | let genre: String 44 | let logo: String 45 | let dj: String? 46 | } 47 | 48 | private let gta5stations: [GTARadioStation] = [ 49 | GTARadioStation( 50 | title: "Los Santos Rock Radio", 51 | genre: "Classic rock, soft rock, pop rock", 52 | logo: "radio_01_class_rock.png", 53 | dj: "Kenny Loggins" 54 | ), 55 | GTARadioStation( 56 | title: "Non-Stop Pop FM", 57 | genre: "Pop music, electronic dance music, electro house", 58 | logo: "radio_02_pop.png", 59 | dj: "Cara Delevingne" 60 | ), 61 | GTARadioStation( 62 | title: "Radio Los Santos", 63 | genre: "Modern contemporary hip hop, trap", 64 | logo: "radio_03_hiphop_new.png", 65 | dj: "Big Boy" 66 | ), 67 | GTARadioStation( 68 | title: "Channel X", 69 | genre: "Punk rock, hardcore punk and grunge", 70 | logo: "radio_04_punk.png", 71 | dj: "Keith Morris" 72 | ), 73 | GTARadioStation( 74 | title: "WCTR: West Coast Talk Radio", 75 | genre: "Public Talk Radio", 76 | logo: "radio_05_talk_01.png", 77 | dj: nil 78 | ), 79 | GTARadioStation( 80 | title: "Rebel Radio", 81 | genre: "Country music and rockabilly", 82 | logo: "radio_06_country.png", 83 | dj: "Jesco White" 84 | ), 85 | GTARadioStation( 86 | title: "Soulwax FM", 87 | genre: "Electronic music", 88 | logo: "radio_07_dance_01.png", 89 | dj: "Soulwax" 90 | ), 91 | GTARadioStation( 92 | title: "East Los FM", 93 | genre: "Mexican music and Latin music", 94 | logo: "radio_08_mexican.png", 95 | dj: "DJ Camilo and Don Cheto" 96 | ), 97 | GTARadioStation( 98 | title: "West Coast Classics", 99 | genre: "Golden age hip hop and gangsta rap", 100 | logo: "radio_09_hiphop_old.png", 101 | dj: "DJ Pooh" 102 | ), 103 | GTARadioStation( 104 | title: "Blaine County Talk Radio", 105 | genre: "Public Talk Radio", 106 | logo: "radio_11_talk_02.png", 107 | dj: nil 108 | ), 109 | GTARadioStation( 110 | title: "Blue Ark", 111 | genre: "Reggae, dancehall and dub", 112 | logo: "radio_12_reggae.png", 113 | dj: "Lee \"Scratch\" Perry" 114 | ), 115 | GTARadioStation( 116 | title: "WorldWide FM", 117 | genre: "Lounge, chillwave, jazz-funk and world", 118 | logo: "radio_13_jazz.png", 119 | dj: "Gilles Peterson" 120 | ), 121 | GTARadioStation( 122 | title: "FlyLo FM", 123 | genre: "IDM and Midwest hip hop", 124 | logo: "radio_14_dance_02.png", 125 | dj: "Flying Lotus" 126 | ), 127 | GTARadioStation( 128 | title: "The Lowdown 91.1", 129 | genre: "Classic soul, disco, gospel", 130 | logo: "radio_15_motown.png", 131 | dj: "Pam Grier" 132 | ), 133 | GTARadioStation( 134 | title: "Radio Mirror Park", 135 | genre: "Indie pop, synthpop, indietronica and chillwave", 136 | logo: "radio_16_silverlake.png", 137 | dj: "Twin Shadow" 138 | ), 139 | GTARadioStation( 140 | title: "Space 103.2", 141 | genre: "Funk and R&B", 142 | logo: "radio_17_funk.png", 143 | dj: "Bootsy Collins" 144 | ), 145 | GTARadioStation( 146 | title: "Vinewood Boulevard Radio", 147 | genre: "Garage rock, alternative rock and noise rock", 148 | logo: "radio_18_90s_rock.png", 149 | dj: "Nate Williams and Stephen Pope" 150 | ) 151 | ] 152 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/MediaList/MediaListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MediaListView.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 09.12.2024. 6 | // 7 | 8 | import Kingfisher 9 | import SwiftUI 10 | 11 | struct MediaListView: View { 12 | @Environment(PlayListController.self) var model 13 | @Environment(\.nowPlayingExpandProgress) var expandProgress 14 | 15 | var body: some View { 16 | NavigationStack { 17 | ScrollView { 18 | content 19 | } 20 | .contentMargins(.bottom, ViewConst.tabbarHeight + 27, for: .scrollContent) 21 | .contentMargins(.bottom, ViewConst.tabbarHeight, for: .scrollIndicators) 22 | .background(Color(.palette.appBackground(expandProgress: expandProgress))) 23 | .toolbar { 24 | Button { 25 | print("Profile tapped") 26 | } 27 | label: { 28 | Image(systemName: "person.crop.circle") 29 | .font(.system(size: 20, weight: .semibold)) 30 | .foregroundStyle(Color(.palette.brand)) 31 | } 32 | } 33 | } 34 | } 35 | } 36 | 37 | private extension MediaListView { 38 | var content: some View { 39 | VStack(spacing: 0) { 40 | header 41 | .padding(.horizontal, ViewConst.screenPaddings) 42 | .padding(.top, 7) 43 | buttons 44 | .padding(.horizontal, ViewConst.screenPaddings) 45 | .padding(.top, 14) 46 | list 47 | .padding(.top, 26) 48 | footer 49 | .padding(.top, 17) 50 | } 51 | } 52 | 53 | var header: some View { 54 | VStack(spacing: 0) { 55 | let border = UIScreen.hairlineWidth 56 | KFImage.url(model.display.artwork) 57 | .resizable() 58 | .aspectRatio(contentMode: .fill) 59 | .background(Color(.palette.artworkBackground)) 60 | .clipShape(.rect(cornerRadius: 10)) 61 | .overlay( 62 | RoundedRectangle(cornerRadius: 10) 63 | .inset(by: border / 2) 64 | .stroke(Color(.palette.artworkBorder), lineWidth: border) 65 | ) 66 | .padding(.horizontal, 52) 67 | 68 | Text(model.display.title) 69 | .font(.appFont.mediaListHeaderTitle) 70 | .padding(.top, 18) 71 | 72 | if let subtitle = model.display.subtitle { 73 | Text(subtitle) 74 | .font(.appFont.mediaListHeaderSubtitle) 75 | .foregroundStyle(Color(.palette.textSecondary)) 76 | .padding(.top, 2) 77 | } 78 | } 79 | } 80 | 81 | var buttons: some View { 82 | HStack(spacing: 16) { 83 | Button { 84 | print("Play") 85 | } 86 | label: { 87 | Label("Play", systemImage: "play.fill") 88 | } 89 | 90 | Button { 91 | print("Shuffle") 92 | } 93 | label: { 94 | Label("Shuffle", systemImage: "shuffle") 95 | } 96 | } 97 | .buttonStyle(AppleMusicButton()) 98 | .font(.appFont.button) 99 | } 100 | 101 | var list: some View { 102 | VStack(alignment: .leading, spacing: 0) { 103 | ForEach(Array(model.display.items.enumerated()), id: \.offset) { offset, item in 104 | VStack(spacing: 0) { 105 | let isFirstItem = offset == 0 106 | let isLastItem = offset == model.items.count - 1 107 | if isFirstItem { 108 | Divider() 109 | } 110 | MediaItemView( 111 | artwork: item.artwork, 112 | title: item.title, 113 | subtitle: item.subtitle, 114 | divider: isLastItem ? .long : .short 115 | ) 116 | } 117 | .padding(.leading, ViewConst.screenPaddings) 118 | } 119 | } 120 | } 121 | 122 | @ViewBuilder 123 | var footer: some View { 124 | if let text = model.footer { 125 | Text(text) 126 | .frame(maxWidth: .infinity, alignment: .leading) 127 | .padding(.horizontal, ViewConst.screenPaddings) 128 | .foregroundStyle(Color(.palette.textTertiary)) 129 | .font(.appFont.mediaListItemFooter) 130 | } 131 | } 132 | } 133 | 134 | struct AppleMusicButton: ButtonStyle { 135 | func makeBody(configuration: Configuration) -> some View { 136 | configuration.label 137 | .frame(height: 48) 138 | .frame(maxWidth: .infinity) 139 | .background(Color(.palette.buttonBackground)) 140 | .foregroundStyle(Color(.palette.brand)) 141 | .clipShape(.rect(cornerRadius: 10)) 142 | .opacity(configuration.isPressed ? 0.65 : 1) 143 | } 144 | } 145 | 146 | struct MediaItemView: View { 147 | enum DividerType { 148 | case short 149 | case long 150 | } 151 | 152 | let artwork: URL? 153 | let title: String 154 | let subtitle: String? 155 | let divider: DividerType 156 | 157 | var body: some View { 158 | VStack(spacing: 0) { 159 | HStack(spacing: 12) { 160 | let border = UIScreen.hairlineWidth 161 | KFImage.url(artwork) 162 | .resizable() 163 | .frame(width: 48, height: 48) 164 | .aspectRatio(contentMode: .fill) 165 | .background(Color(.palette.artworkBackground)) 166 | .clipShape(.rect(cornerRadius: 5)) 167 | .overlay( 168 | RoundedRectangle(cornerRadius: 5) 169 | .inset(by: border / 2) 170 | .stroke(Color(.palette.artworkBorder), lineWidth: border) 171 | ) 172 | 173 | VStack(alignment: .leading, spacing: 2) { 174 | Text(title) 175 | .font(.appFont.mediaListItemTitle) 176 | Text(subtitle ?? "") 177 | .font(.appFont.mediaListItemSubtitle) 178 | .foregroundStyle(Color(.palette.textTertiary)) 179 | } 180 | .frame(maxWidth: .infinity, alignment: .leading) 181 | .lineLimit(1) 182 | } 183 | .padding(.top, 4) 184 | Spacer(minLength: 0) 185 | Divider() 186 | .padding(.leading, divider == .long ? 0 : 60) 187 | } 188 | .frame(height: 56) 189 | } 190 | } 191 | 192 | #Preview { 193 | MediaListView() 194 | .environment(PlayListController()) 195 | } 196 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/MediaList/PlayListController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayListController.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 30.11.2024. 6 | // 7 | 8 | import Observation 9 | import SwiftUI 10 | 11 | @Observable 12 | class PlayListController { 13 | var library = MediaLibrary() 14 | var current: MediaList? 15 | 16 | init() { 17 | selectFirstAvailable() 18 | } 19 | 20 | var display: MediaList { 21 | current ?? .placeholder 22 | } 23 | 24 | var items: [Media] { 25 | current?.items ?? [] 26 | } 27 | 28 | var footer: LocalizedStringKey? { 29 | current.map { "^[\($0.items.count) station](inflect: true)" } 30 | } 31 | 32 | func selectFirstAvailable() { 33 | current = library.list.first { !$0.items.isEmpty } 34 | } 35 | } 36 | 37 | private extension MediaList { 38 | static var placeholder: Self { 39 | MediaList( 40 | artwork: nil, 41 | title: "---", 42 | subtitle: nil, 43 | items: [] 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/NowPlaying/CompactNowPlaying.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompactNowPlaying.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 20.11.2024. 6 | // 7 | 8 | import Kingfisher 9 | import SwiftUI 10 | 11 | struct CompactNowPlaying: View { 12 | @Environment(NowPlayingController.self) var model 13 | @Binding var expanded: Bool 14 | var hideArtworkOnExpanded: Bool = true 15 | var animationNamespace: Namespace.ID 16 | @State var forwardAnimationTrigger: PlayerButtonTrigger = .one(bouncing: false) 17 | 18 | var body: some View { 19 | HStack(spacing: 8) { 20 | artwork 21 | .frame(width: 40, height: 40) 22 | 23 | Text(model.title) 24 | .lineLimit(1) 25 | .font(.appFont.miniPlayerTitle) 26 | .padding(.trailing, -18) 27 | 28 | Spacer(minLength: 0) 29 | 30 | PlayerButton( 31 | label: { 32 | PlayerButtonLabel(type: model.playPauseButton, size: 20) 33 | }, 34 | onEnded: { 35 | model.onPlayPause() 36 | } 37 | ) 38 | .playerButtonStyle(.miniPlayer) 39 | 40 | PlayerButton( 41 | label: { 42 | PlayerButtonLabel( 43 | type: model.forwardButton, 44 | size: 30, 45 | animationTrigger: forwardAnimationTrigger 46 | ) 47 | }, 48 | onEnded: { 49 | model.onForward() 50 | forwardAnimationTrigger.toggle(bouncing: true) 51 | } 52 | ) 53 | .playerButtonStyle(.miniPlayer) 54 | } 55 | .padding(.horizontal, 8) 56 | .frame(height: ViewConst.compactNowPlayingHeight) 57 | .contentShape(.rect) 58 | .transformEffect(.identity) 59 | .onTapGesture { 60 | withAnimation(.playerExpandAnimation) { 61 | expanded = true 62 | } 63 | } 64 | } 65 | } 66 | 67 | private extension CompactNowPlaying { 68 | @ViewBuilder 69 | var artwork: some View { 70 | if !hideArtworkOnExpanded || !expanded { 71 | KFImage.url(model.display.artwork) 72 | .resizable() 73 | .aspectRatio(contentMode: .fill) 74 | .background(Color(UIColor.systemGray4)) 75 | .clipShape(.rect(cornerRadius: 7)) 76 | .matchedGeometryEffect( 77 | id: PlayerMatchedGeometry.artwork, 78 | in: animationNamespace 79 | ) 80 | } 81 | } 82 | } 83 | 84 | extension PlayerButtonConfig { 85 | static var miniPlayer: Self { 86 | Self( 87 | size: 44, 88 | tint: .init(Palette.PlayerCard.translucent.withAlphaComponent(0.3)) 89 | ) 90 | } 91 | } 92 | 93 | #Preview { 94 | CompactNowPlaying( 95 | expanded: .constant(false), 96 | animationNamespace: Namespace().wrappedValue 97 | ) 98 | .background(.gray) 99 | .environment( 100 | NowPlayingController( 101 | playList: PlayListController(), 102 | player: Player() 103 | ) 104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/NowPlaying/ExpandableNowPlaying.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ExpandableNowPlaying.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 17.11.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum PlayerMatchedGeometry { 11 | case artwork 12 | } 13 | 14 | struct ExpandableNowPlaying: View { 15 | @Binding var show: Bool 16 | @Binding var expanded: Bool 17 | @Environment(NowPlayingController.self) var model 18 | @State private var offsetY: CGFloat = 0.0 19 | @State private var mainWindow: UIWindow? 20 | @State private var needRestoreProgressOnActive: Bool = false 21 | @State private var windowProgress: CGFloat = 0.0 22 | @State private var progressTrackState: CGFloat = 0.0 23 | @State private var expandProgress: CGFloat = 0.0 24 | @Environment(\.colorScheme) var colorScheme 25 | @Namespace private var animationNamespace 26 | 27 | var body: some View { 28 | expandableNowPlaying 29 | .onAppear { 30 | if let window = UIApplication.keyWindow { 31 | mainWindow = window 32 | } 33 | model.onAppear() 34 | } 35 | .onChange(of: expanded) { 36 | if expanded { 37 | stacked(progress: 1, withAnimation: true) 38 | } 39 | } 40 | .onPreferenceChange(NowPlayingExpandProgressPreferenceKey.self) { value in 41 | expandProgress = value 42 | } 43 | } 44 | } 45 | 46 | private extension ExpandableNowPlaying { 47 | var isFullExpanded: Bool { 48 | expandProgress >= 1 49 | } 50 | 51 | var expandableNowPlaying: some View { 52 | GeometryReader { 53 | let size = $0.size 54 | let safeArea = $0.safeAreaInsets 55 | 56 | ZStack(alignment: .top) { 57 | NowPlayingBackground( 58 | colors: model.colors.map { Color($0.color) }, 59 | expanded: expanded, 60 | isFullExpanded: isFullExpanded 61 | ) 62 | CompactNowPlaying( 63 | expanded: $expanded, 64 | animationNamespace: animationNamespace 65 | ) 66 | .opacity(expanded ? 0 : 1) 67 | 68 | RegularNowPlaying( 69 | expanded: $expanded, 70 | size: size, 71 | safeArea: safeArea, 72 | animationNamespace: animationNamespace 73 | ) 74 | .opacity(expanded ? 1 : 0) 75 | ProgressTracker(progress: progressTrackState) 76 | } 77 | .frame(height: expanded ? nil : ViewConst.compactNowPlayingHeight, alignment: .top) 78 | .frame(maxHeight: .infinity, alignment: .bottom) 79 | .padding(.bottom, expanded ? 0 : safeArea.bottom + ViewConst.compactNowPlayingHeight) 80 | .padding(.horizontal, expanded ? 0 : 12) 81 | .offset(y: offsetY) 82 | .gesture( 83 | PanGesture( 84 | onChange: { handleGestureChange(value: $0, viewSize: size) }, 85 | onEnd: { handleGestureEnd(value: $0, viewSize: size) } 86 | ) 87 | ) 88 | .ignoresSafeArea() 89 | } 90 | } 91 | 92 | func handleGestureChange(value: PanGesture.Value, viewSize: CGSize) { 93 | guard expanded else { return } 94 | let translation = max(value.translation.height, 0) 95 | offsetY = translation 96 | windowProgress = max(min(translation / viewSize.height, 1), 0) 97 | stacked(progress: 1 - windowProgress, withAnimation: false) 98 | } 99 | 100 | func handleGestureEnd(value: PanGesture.Value, viewSize: CGSize) { 101 | guard expanded else { return } 102 | let translation = max(value.translation.height, 0) 103 | let velocity = value.velocity.height / 5 104 | withAnimation(.playerExpandAnimation) { 105 | if (translation + velocity) > (viewSize.height * 0.3) { 106 | expanded = false 107 | resetStackedWithAnimation() 108 | } else { 109 | stacked(progress: 1, withAnimation: true) 110 | } 111 | offsetY = 0 112 | } 113 | } 114 | 115 | func stacked(progress: CGFloat, withAnimation: Bool) { 116 | if withAnimation { 117 | SwiftUI.withAnimation(.playerExpandAnimation) { 118 | progressTrackState = progress 119 | } 120 | } else { 121 | progressTrackState = progress 122 | } 123 | 124 | mainWindow?.stacked( 125 | progress: progress, 126 | animationDuration: withAnimation ? Animation.playerExpandAnimationDuration : nil 127 | ) 128 | } 129 | 130 | func resetStackedWithAnimation() { 131 | withAnimation(.playerExpandAnimation) { 132 | progressTrackState = 0 133 | } 134 | mainWindow?.resetStackedWithAnimation(duration: Animation.playerExpandAnimationDuration) 135 | } 136 | } 137 | 138 | extension Animation { 139 | static let playerExpandAnimationDuration: TimeInterval = 0.3 140 | static var playerExpandAnimation: Animation { 141 | .smooth(duration: playerExpandAnimationDuration, extraBounce: 0) 142 | } 143 | } 144 | 145 | private struct ProgressTracker: View, Animatable { 146 | var progress: CGFloat = 0 147 | 148 | nonisolated var animatableData: CGFloat { 149 | get { progress } 150 | set { progress = newValue } 151 | } 152 | 153 | var body: some View { 154 | Color.clear 155 | .frame(width: 1, height: 1) 156 | .preference(key: NowPlayingExpandProgressPreferenceKey.self, value: progress) 157 | } 158 | } 159 | 160 | private extension UIWindow { 161 | func stacked(progress: CGFloat, animationDuration: TimeInterval?) { 162 | if let animationDuration { 163 | UIView.animate( 164 | withDuration: animationDuration, 165 | animations: { 166 | self.stacked(progress: progress) 167 | }, 168 | completion: { _ in 169 | delay(animationDuration) { 170 | DispatchQueue.main.async { 171 | self.resetStacked() 172 | } 173 | } 174 | } 175 | ) 176 | } else { 177 | stacked(progress: progress) 178 | } 179 | } 180 | 181 | private func stacked(progress: CGFloat) { 182 | let offsetY = progress * 10 183 | layer.cornerRadius = 22 184 | layer.masksToBounds = true 185 | 186 | let scale = 1 - progress * 0.1 187 | transform = .identity 188 | .scaledBy(x: scale, y: scale) 189 | .translatedBy(x: 0, y: offsetY) 190 | } 191 | 192 | func resetStackedWithAnimation(duration: TimeInterval) { 193 | UIView.animate(withDuration: duration) { 194 | DispatchQueue.main.async { 195 | self.resetStacked() 196 | } 197 | } 198 | } 199 | 200 | private func resetStacked() { 201 | layer.cornerRadius = 0.0 202 | transform = .identity 203 | } 204 | } 205 | 206 | #Preview { 207 | OverlayableRootView { 208 | OverlaidRootView() 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/NowPlaying/NowPlayingBackground.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NowPlayingBackground.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 02.01.2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NowPlayingBackground: View { 11 | let colors: [Color] 12 | let expanded: Bool 13 | let isFullExpanded: Bool 14 | var canBeExpanded: Bool = true 15 | 16 | @Environment(\.colorScheme) var colorScheme 17 | 18 | var body: some View { 19 | let expandPlayerCornerRadius = (isFullExpanded ? 0 : UIScreen.deviceCornerRadius) 20 | return ZStack { 21 | Rectangle() 22 | .fill(.thickMaterial) 23 | if canBeExpanded { 24 | ColorfulBackground(colors: colors) 25 | .overlay(Color(UIColor(white: 0.4, alpha: 0.5))) 26 | .opacity(expanded ? 1 : 0) 27 | } 28 | } 29 | .clipShape(.rect(cornerRadius: expanded ? expandPlayerCornerRadius : 14)) 30 | .frame(height: expanded ? nil : ViewConst.compactNowPlayingHeight) 31 | .shadow( 32 | color: .primary.opacity(colorScheme == .light ? 0.2 : 0), 33 | radius: 8, 34 | x: 0, 35 | y: 2 36 | ) 37 | } 38 | } 39 | 40 | #Preview { 41 | NowPlayingBackground( 42 | colors: [], 43 | expanded: false, 44 | isFullExpanded: false 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/NowPlaying/NowPlayingExpandTracking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NowPlayingExpandTracking.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 26.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NowPlayingExpandProgressPreferenceKey: PreferenceKey { 11 | static let defaultValue: CGFloat = .zero 12 | static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { 13 | value = nextValue() 14 | } 15 | } 16 | 17 | private struct NowPlayingExpandProgressEnvironmentKey: EnvironmentKey { 18 | static let defaultValue: Double = .zero 19 | } 20 | 21 | extension EnvironmentValues { 22 | var nowPlayingExpandProgress: CGFloat { 23 | get { self[NowPlayingExpandProgressEnvironmentKey.self] } 24 | set { self[NowPlayingExpandProgressEnvironmentKey.self] = newValue } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/NowPlaying/PlayerControls/ForwardLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ForwardLabel.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 31.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AnimatedForwardLabel: View { 11 | class PublishedWrapper: ObservableObject { 12 | @Published var trigger: PlayerButtonTrigger = .one(bouncing: false) 13 | } 14 | 15 | let size: CGFloat 16 | var trigger: PlayerButtonTrigger 17 | var animationDuration: Double = 0.3 18 | @State var changeCount = 0 19 | @StateObject var published = PublishedWrapper() 20 | @State var animationTrigger: Bool = true 21 | @State var bouncing: Bool = false 22 | 23 | var body: some View { 24 | AnimationWrapper(size: size, linear: !bouncing, progress: animationTrigger ? 0 : 1) 25 | .onChange(of: trigger) { 26 | published.trigger = trigger 27 | } 28 | .onReceive( 29 | published.$trigger.dropFirst().throttle( 30 | for: RunLoop.SchedulerTimeType.Stride(animationDuration), 31 | scheduler: RunLoop.main, 32 | latest: true 33 | ) 34 | ) { value in 35 | switch value { 36 | case let .one(bouncing): self.bouncing = bouncing 37 | case let .another(bouncing): self.bouncing = bouncing 38 | } 39 | withAnimation(.linear(duration: animationDuration * 0.9)) { 40 | animationTrigger.toggle() 41 | } completion: { 42 | animationTrigger.toggle() 43 | } 44 | } 45 | } 46 | } 47 | 48 | private struct ForwardLabel: View { 49 | let size: CGFloat 50 | var linear: Bool = false 51 | var progress: Double 52 | let sideFraction = 0.3 53 | 54 | var body: some View { 55 | ZStack { 56 | HStack(spacing: 0) { 57 | gliph 58 | .padding(.leading, leftOffset) 59 | .opacity(progress) 60 | 61 | gliph 62 | .frame(width: size / 2) 63 | 64 | gliph 65 | .frame(width: rightWidth) 66 | .padding(.trailing, rightOffset) 67 | .opacity(1 - progress) 68 | } 69 | } 70 | .frame(width: width, height: size) 71 | } 72 | 73 | var side: CGFloat { size * sideFraction } 74 | var width: CGFloat { size + side * 2 } 75 | 76 | var leftOffset: CGFloat { 77 | let res = min(sideFraction, progress) * size + 78 | (1 - leftGrowthProgress) * side + 79 | rightProtrusionProgress * side 80 | return res 81 | } 82 | 83 | var rightOffset: CGFloat { 84 | let res = max(0, sideFraction - progress) * size 85 | + rightShrinkProgress * side 86 | return res 87 | } 88 | 89 | var rightWidth: CGFloat { 90 | let res = lerp(0, size / 2, 1 - scaleProgress) 91 | return res 92 | } 93 | 94 | var scaleProgress: Double { 95 | max(0, (progress - sideFraction) / (1.0 - sideFraction)) 96 | } 97 | 98 | var leftGrowthProgress: Double { 99 | let progress = min(1, progress / sideFraction) 100 | return linear ? progress : sqrt(progress) 101 | } 102 | 103 | var linearRightShrinkProgress: Double { 104 | let progress = max(0, scaleProgress - (1 - sideFraction * 2)) / sideFraction / 2 105 | return progress 106 | } 107 | 108 | var rightShrinkProgress: Double { 109 | return linear ? linearRightShrinkProgress : linearRightShrinkProgress * linearRightShrinkProgress 110 | } 111 | 112 | var rightProtrusionProgress: Double { 113 | linearRightShrinkProgress - rightShrinkProgress 114 | } 115 | 116 | var gliph: some View { 117 | Image(systemName: "play.fill") 118 | .resizable() 119 | .aspectRatio(contentMode: .fit) 120 | } 121 | } 122 | 123 | private struct AnimationWrapper: View, Animatable { 124 | let size: CGFloat 125 | let linear: Bool 126 | var progress: Double 127 | 128 | nonisolated var animatableData: CGFloat { 129 | get { CGFloat(progress) } 130 | set { progress = Double(newValue) } 131 | } 132 | 133 | var body: some View { 134 | ForwardLabel( 135 | size: size, 136 | linear: linear, 137 | progress: progress.clamped( 138 | to: 0 ... 1 139 | ) 140 | ) 141 | } 142 | } 143 | 144 | #Preview { 145 | @Previewable let size: CGFloat = 50 146 | @Previewable @State var trigger: PlayerButtonTrigger = .one(bouncing: false) 147 | VStack(spacing: 30) { 148 | AnimatedForwardLabel(size: size, trigger: trigger) 149 | 150 | Button("Animate bouncing") { 151 | trigger.toggle(bouncing: true) 152 | } 153 | .padding(20) 154 | 155 | Button("Animate linea") { 156 | trigger.toggle(bouncing: false) 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/NowPlaying/PlayerControls/PlayerButtonLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerButtonLabel.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 28.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum ButtonType { 11 | case play 12 | case stop 13 | case pause 14 | case backward 15 | case forward 16 | } 17 | 18 | enum PlayerButtonTrigger: Equatable { 19 | case one(bouncing: Bool) 20 | case another(bouncing: Bool) 21 | } 22 | 23 | struct PlayerButtonLabel: View { 24 | let type: ButtonType 25 | let size: CGFloat 26 | var animationTrigger: PlayerButtonTrigger 27 | 28 | init(type: ButtonType, size: CGFloat, animationTrigger: PlayerButtonTrigger? = nil) { 29 | self.type = type 30 | self.size = size 31 | self.animationTrigger = animationTrigger ?? .one(bouncing: false) 32 | } 33 | 34 | var body: some View { 35 | switch type { 36 | case .forward: 37 | AnimatedForwardLabel(size: size, trigger: animationTrigger) 38 | case .backward: 39 | AnimatedForwardLabel(size: size, trigger: animationTrigger) 40 | .scaleEffect(x: -1) 41 | default: 42 | Image(systemName: type.systemImageName) 43 | .resizable() 44 | .aspectRatio(contentMode: .fit) 45 | .frame(width: size, height: size) 46 | } 47 | } 48 | } 49 | 50 | extension PlayerButtonTrigger { 51 | mutating func toggle(bouncing: Bool) { 52 | switch self { 53 | case .one: self = .another(bouncing: bouncing) 54 | case .another: self = .one(bouncing: bouncing) 55 | } 56 | } 57 | } 58 | 59 | extension ButtonType { 60 | var systemImageName: String { 61 | switch self { 62 | case .play: "play.fill" 63 | case .stop: "stop.fill" 64 | case .pause: "pause.fill" 65 | case .backward: "backward.fill" 66 | case .forward: "forward.fill" 67 | } 68 | } 69 | } 70 | 71 | private struct Label: View { 72 | let size: CGFloat 73 | var progress: Double 74 | var body: some View { 75 | ZStack { 76 | HStack(spacing: 0) { 77 | gliph 78 | .frame(maxWidth: lerp(0.5, size / 2, progress)) 79 | .opacity(lerp(0.1, 0.9, progress)) 80 | gliph 81 | gliph 82 | .frame(maxWidth: lerp(size / 2, 0.5, progress)) 83 | .opacity(lerp(1, 0.1, progress)) 84 | } 85 | } 86 | .frame(width: size, height: size) 87 | } 88 | 89 | var gliph: some View { 90 | Image(systemName: "play.fill") 91 | .resizable() 92 | .aspectRatio(contentMode: .fit) 93 | } 94 | } 95 | 96 | #Preview { 97 | @Previewable @State var trigger: PlayerButtonTrigger = .one(bouncing: true) 98 | VStack(spacing: 16) { 99 | PlayerButtonLabel(type: .play, size: 50) 100 | PlayerButtonLabel(type: .backward, size: 50, animationTrigger: trigger) 101 | PlayerButtonLabel(type: .forward, size: 50, animationTrigger: trigger) 102 | Button("Animate") { 103 | trigger.toggle(bouncing: true) 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/NowPlaying/PlayerControls/PlayerButtons.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerButtons.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 15.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PlayerButtons: View { 11 | @Environment(NowPlayingController.self) var model 12 | let spacing: CGFloat 13 | let imageSize: CGFloat = 34 14 | @State var backwardAnimationTrigger: PlayerButtonTrigger = .one(bouncing: false) 15 | @State var forwardAnimationTrigger: PlayerButtonTrigger = .one(bouncing: false) 16 | 17 | var body: some View { 18 | HStack(spacing: spacing) { 19 | PlayerButton( 20 | label: { 21 | PlayerButtonLabel( 22 | type: model.backwardButton, 23 | size: imageSize, 24 | animationTrigger: backwardAnimationTrigger 25 | ) 26 | }, 27 | onEnded: { 28 | backwardAnimationTrigger.toggle(bouncing: true) 29 | model.onBackward() 30 | } 31 | ) 32 | 33 | PlayerButton( 34 | label: { 35 | PlayerButtonLabel(type: model.playPauseButton, size: imageSize) 36 | }, 37 | onEnded: { 38 | model.onPlayPause() 39 | } 40 | ) 41 | 42 | PlayerButton( 43 | label: { 44 | PlayerButtonLabel( 45 | type: model.forwardButton, 46 | size: imageSize, 47 | animationTrigger: forwardAnimationTrigger 48 | ) 49 | }, 50 | onEnded: { 51 | forwardAnimationTrigger.toggle(bouncing: true) 52 | model.onForward() 53 | } 54 | ) 55 | } 56 | .playerButtonStyle(.expandedPlayer) 57 | } 58 | } 59 | 60 | extension PlayerButtonConfig { 61 | static var expandedPlayer: Self { 62 | Self( 63 | labelColor: .init(Palette.PlayerCard.opaque), 64 | tint: .init(Palette.PlayerCard.translucent.withAlphaComponent(0.3)), 65 | pressedColor: .init(Palette.PlayerCard.opaque) 66 | ) 67 | } 68 | } 69 | 70 | #Preview { 71 | ZStack(alignment: .top) { 72 | PreviewBackground() 73 | VStack { 74 | Text("Header") 75 | .blendMode(.overlay) 76 | PlayerButtons(spacing: UIScreen.main.bounds.size.width * 0.14) 77 | Text("Footer") 78 | .blendMode(.overlay) 79 | } 80 | .foregroundStyle(Color(Palette.PlayerCard.opaque)) 81 | } 82 | .environment( 83 | NowPlayingController(playList: PlayListController(), player: Player()) 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/NowPlaying/PlayerControls/PlayerControls.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerControls.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 01.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PlayerControls: View { 11 | @Environment(NowPlayingController.self) var model 12 | @State private var volume: Double = 0.5 13 | 14 | var body: some View { 15 | GeometryReader { 16 | let size = $0.size 17 | let spacing = size.verticalSpacing 18 | VStack(spacing: 0) { 19 | VStack(spacing: spacing) { 20 | trackInfo 21 | let indicatorPadding = ViewConst.playerCardPaddings - ElasticSliderConfig.playbackProgress.growth 22 | TimingIndicator(spacing: spacing) 23 | .padding(.top, spacing) 24 | .padding(.horizontal, indicatorPadding) 25 | } 26 | .frame(height: size.height / 2.5, alignment: .top) 27 | PlayerButtons(spacing: size.width * 0.14) 28 | .padding(.horizontal, ViewConst.playerCardPaddings) 29 | volume(playerSize: size) 30 | .frame(height: size.height / 2.5, alignment: .bottom) 31 | } 32 | } 33 | } 34 | } 35 | 36 | private extension CGSize { 37 | var verticalSpacing: CGFloat { height * 0.04 } 38 | } 39 | 40 | private extension PlayerControls { 41 | var palette: Palette.PlayerCard.Type { 42 | UIColor.palette.playerCard.self 43 | } 44 | 45 | var trackInfo: some View { 46 | HStack(alignment: .center, spacing: 15) { 47 | VStack(alignment: .leading, spacing: 4) { 48 | let fade = ViewConst.playerCardPaddings 49 | let cfg = MarqueeText.Config(leftFade: fade, rightFade: fade) 50 | MarqueeText(model.display.title, config: cfg) 51 | .transformEffect(.identity) 52 | .font(.title3) 53 | .fontWeight(.semibold) 54 | .foregroundStyle(Color(palette.opaque)) 55 | .id(model.display.title) 56 | MarqueeText(model.display.subtitle ?? "", config: cfg) 57 | .transformEffect(.identity) 58 | .foregroundStyle(Color(palette.opaque)) 59 | .blendMode(.overlay) 60 | .id(model.display.subtitle) 61 | } 62 | .frame(maxWidth: .infinity, alignment: .leading) 63 | } 64 | } 65 | 66 | func volume(playerSize: CGSize) -> some View { 67 | VStack(spacing: playerSize.verticalSpacing) { 68 | VolumeSlider() 69 | .padding(.horizontal, 8) 70 | 71 | footer(width: playerSize.width) 72 | .padding(.top, playerSize.verticalSpacing) 73 | .padding(.horizontal, ViewConst.playerCardPaddings) 74 | } 75 | } 76 | 77 | func footer(width: CGFloat) -> some View { 78 | HStack(alignment: .top, spacing: width * 0.18) { 79 | Button {} label: { 80 | Image(systemName: "quote.bubble") 81 | .font(.title2) 82 | } 83 | VStack(spacing: 6) { 84 | Button {} label: { 85 | Image(systemName: "airpods.gen3") 86 | .font(.title2) 87 | } 88 | Text("iPhone's Airpods") 89 | .font(.caption) 90 | } 91 | Button {} label: { 92 | Image(systemName: "list.bullet") 93 | .font(.title2) 94 | } 95 | } 96 | .foregroundStyle(Color(palette.opaque)) 97 | .blendMode(.overlay) 98 | } 99 | } 100 | 101 | #Preview { 102 | ZStack(alignment: .bottom) { 103 | PreviewBackground() 104 | PlayerControls() 105 | .frame(height: 400) 106 | } 107 | .environment( 108 | NowPlayingController( 109 | playList: PlayListController(), 110 | player: Player() 111 | ) 112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/NowPlaying/PlayerControls/PreviewBackground.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewBackground.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 25.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PreviewBackground: View { 11 | var body: some View { 12 | ColorfulBackground( 13 | colors: [ 14 | UIColor(red: 0.85, green: 0.7, blue: 0.6, alpha: 1.0), 15 | UIColor(red: 0.15, green: 0.3, blue: 0.2, alpha: 1.0) 16 | ].map { Color($0) } 17 | ) 18 | .overlay(Color(UIColor(white: 0.4, alpha: 0.5))) 19 | .ignoresSafeArea() 20 | } 21 | } 22 | 23 | #Preview { 24 | struct ColorView: View { 25 | let uiColor: UIColor 26 | var body: some View { 27 | Color(uiColor) 28 | .frame(width: 60, height: 60) 29 | } 30 | } 31 | 32 | struct ColorsView: View { 33 | @State var white: CGFloat = 0.784 34 | @State var alpha: CGFloat = 0.816 35 | @State var hidden: Bool = false 36 | var body: some View { 37 | VStack(spacing: 0) { 38 | HStack(spacing: 0) { 39 | ColorView(uiColor: Palette.PlayerCard.opaque) 40 | ZStack { 41 | ColorView(uiColor: Palette.PlayerCard.translucent) 42 | ColorView(uiColor: Palette.PlayerCard.translucent) 43 | } 44 | ColorView(uiColor: Palette.PlayerCard.translucent) 45 | } 46 | .blendMode(.overlay) 47 | .hidden(hidden) 48 | 49 | HStack(spacing: 0) { 50 | ColorView(uiColor: Palette.PlayerCard.opaque) 51 | ZStack { 52 | ColorView(uiColor: color) 53 | ColorView(uiColor: color) 54 | } 55 | ColorView(uiColor: color) 56 | } 57 | .blendMode(.overlay) 58 | 59 | var color: UIColor { 60 | .init(white: white, alpha: alpha) 61 | } 62 | 63 | VStack(spacing: 30) { 64 | Slider(value: $white, in: 0 ... 1) 65 | .padding(.top, 10) 66 | Slider(value: $alpha, in: 0 ... 1) 67 | 68 | Text("White: \(white)") 69 | Text("Alpha: \(alpha)") 70 | Button("Hide") { 71 | hidden.toggle() 72 | } 73 | } 74 | .foregroundStyle(.white) 75 | .padding(60) 76 | } 77 | } 78 | } 79 | 80 | return ZStack { 81 | PreviewBackground() 82 | ColorsView() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/NowPlaying/PlayerControls/TimingIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimingIndicator.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 13.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct TimingIndicator: View { 11 | let spacing: CGFloat 12 | @State var progress: Double = 60 13 | let range = 0.0 ... 194 14 | 15 | var body: some View { 16 | ElasticSlider( 17 | value: $progress, 18 | in: range, 19 | leadingLabel: { 20 | label(leadingLabelText) 21 | }, 22 | trailingLabel: { 23 | label(trailingLabelText) 24 | } 25 | ) 26 | .sliderStyle(.playbackProgress) 27 | .frame(height: 60) 28 | .transformEffect(.identity) 29 | } 30 | } 31 | 32 | private extension TimingIndicator { 33 | func label(_ text: String) -> some View { 34 | Text(text) 35 | .font(.appFont.timingIndicator) 36 | .padding(.top, 11) 37 | } 38 | 39 | var leadingLabelText: String { 40 | progress.asTimeString(style: .positional) 41 | } 42 | 43 | var trailingLabelText: String { 44 | ((range.upperBound - progress) * -1.0).asTimeString(style: .positional) 45 | } 46 | 47 | var palette: Palette.PlayerCard.Type { 48 | UIColor.palette.playerCard.self 49 | } 50 | } 51 | 52 | extension ElasticSliderConfig { 53 | static var playbackProgress: Self { 54 | Self( 55 | labelLocation: .bottom, 56 | maxStretch: 0, 57 | minimumTrackActiveColor: Color(Palette.PlayerCard.opaque), 58 | minimumTrackInactiveColor: Color(Palette.PlayerCard.translucent), 59 | maximumTrackColor: Color(Palette.PlayerCard.translucent), 60 | blendMode: .overlay, 61 | syncLabelsStyle: true 62 | ) 63 | } 64 | } 65 | 66 | #Preview { 67 | ZStack { 68 | PreviewBackground() 69 | TimingIndicator(spacing: 10) 70 | .padding(.horizontal) 71 | } 72 | } 73 | 74 | extension BinaryFloatingPoint { 75 | func asTimeString(style: DateComponentsFormatter.UnitsStyle) -> String { 76 | let formatter = DateComponentsFormatter() 77 | formatter.allowedUnits = [.minute, .second] 78 | formatter.unitsStyle = style 79 | formatter.zeroFormattingBehavior = .pad 80 | return formatter.string(from: TimeInterval(self)) ?? "" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/NowPlaying/PlayerControls/VolumeSlider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VolumeSlider.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 24.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct VolumeSlider: View { 11 | @State var volume: Double = 0.5 12 | @State var minVolumeAnimationTrigger: Bool = false 13 | @State var maxVolumeAnimationTrigger: Bool = false 14 | let range = 0.0 ... 1 15 | 16 | public var body: some View { 17 | ElasticSlider( 18 | value: $volume, 19 | in: range, 20 | leadingLabel: { 21 | Image(systemName: "speaker.fill") 22 | .padding(.trailing, 10) 23 | .symbolEffect(.bounce, value: minVolumeAnimationTrigger) 24 | }, 25 | trailingLabel: { 26 | Image(systemName: "speaker.wave.3.fill") 27 | .padding(.leading, 10) 28 | .symbolEffect(.bounce, value: maxVolumeAnimationTrigger) 29 | } 30 | ) 31 | .sliderStyle(.volume) 32 | .font(.system(size: 14)) 33 | .onChange(of: volume) { 34 | if volume == range.lowerBound { 35 | minVolumeAnimationTrigger.toggle() 36 | } 37 | if volume == range.upperBound { 38 | maxVolumeAnimationTrigger.toggle() 39 | } 40 | } 41 | .frame(height: 50) 42 | } 43 | } 44 | 45 | extension ElasticSliderConfig { 46 | static var volume: Self { 47 | Self( 48 | labelLocation: .side, 49 | maxStretch: 10, 50 | minimumTrackActiveColor: Color(Palette.PlayerCard.opaque), 51 | minimumTrackInactiveColor: Color(Palette.PlayerCard.translucent), 52 | maximumTrackColor: Color(Palette.PlayerCard.translucent), 53 | blendMode: .overlay, 54 | syncLabelsStyle: true 55 | ) 56 | } 57 | } 58 | 59 | #Preview { 60 | ZStack { 61 | PreviewBackground() 62 | VolumeSlider() 63 | } 64 | .ignoresSafeArea() 65 | } 66 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/NowPlaying/RegularNowPlaying.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RegularNowPlaying.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 20.11.2024. 6 | // 7 | 8 | import Kingfisher 9 | import SwiftUI 10 | 11 | struct RegularNowPlaying: View { 12 | @Environment(NowPlayingController.self) var model 13 | @Binding var expanded: Bool 14 | var size: CGSize 15 | var safeArea: EdgeInsets 16 | var animationNamespace: Namespace.ID 17 | 18 | var body: some View { 19 | VStack(spacing: 12) { 20 | grip 21 | .blendMode(.overlay) 22 | .opacity(expanded ? 1 : 0) 23 | 24 | if expanded { 25 | artwork 26 | .matchedGeometryEffect( 27 | id: PlayerMatchedGeometry.artwork, 28 | in: animationNamespace 29 | ) 30 | .frame(height: size.width - Const.horizontalPadding * 2) 31 | .padding(.vertical, size.height < 700 ? 10 : 30) 32 | .padding(.horizontal, 25) 33 | 34 | PlayerControls() 35 | .transition(.move(edge: .bottom)) 36 | } 37 | } 38 | .padding(.top, safeArea.top) 39 | .padding(.bottom, safeArea.bottom) 40 | } 41 | } 42 | 43 | private extension RegularNowPlaying { 44 | enum Const { 45 | static let horizontalPadding: CGFloat = 25 46 | } 47 | 48 | var grip: some View { 49 | Capsule() 50 | .fill(.white.secondary) 51 | .frame(width: 40, height: 5) 52 | } 53 | 54 | var artwork: some View { 55 | GeometryReader { 56 | let size = $0.size 57 | let small = model.state == .paused 58 | KFImage.url(model.display.artwork) 59 | .resizable() 60 | .aspectRatio(contentMode: .fill) 61 | .background(Color(UIColor.palette.playerCard.artworkBackground)) 62 | .clipShape(RoundedRectangle(cornerRadius: expanded ? 10 : 5, style: .continuous)) 63 | .padding(small ? 48 : 0) 64 | .shadow( 65 | color: Color(.sRGBLinear, white: 0, opacity: small ? 0.13 : 0.33), 66 | radius: small ? 3 : 8, 67 | y: small ? 3 : 10 68 | ) 69 | .frame(width: size.width, height: size.height) 70 | .animation(.smooth, value: model.state) 71 | } 72 | } 73 | } 74 | 75 | #Preview { 76 | @Previewable @State var model = NowPlayingController( 77 | playList: PlayListController(), 78 | player: Player() 79 | ) 80 | 81 | RegularNowPlaying( 82 | expanded: .constant(true), 83 | size: UIScreen.main.bounds.size, 84 | safeArea: (UIApplication.keyWindow?.safeAreaInsets ?? .zero).edgeInsets, 85 | animationNamespace: Namespace().wrappedValue 86 | ) 87 | .onAppear { 88 | model.onAppear() 89 | } 90 | .background { 91 | ColorfulBackground( 92 | colors: model.colors.map { Color($0.color) } 93 | ) 94 | .overlay(Color(UIColor(white: 0.4, alpha: 0.5))) 95 | } 96 | .ignoresSafeArea() 97 | .environment(model) 98 | } 99 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/NowPlayingController/NowPlayingController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NowPlayingController.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 27.11.2024. 6 | // 7 | 8 | import Kingfisher 9 | import Observation 10 | import UIKit 11 | 12 | @MainActor 13 | @Observable 14 | class NowPlayingController { 15 | enum State { 16 | case playing 17 | case paused 18 | } 19 | 20 | var state: State = .paused 21 | var currentIndex: Int? = 1 22 | private let playList: PlayListController 23 | private let player: Player 24 | var colors: [ColorFrequency] = [] 25 | 26 | var currentMedia: Media? { 27 | guard let currentIndex else { return nil } 28 | return playList.items[safe: currentIndex] 29 | } 30 | 31 | init(playList: PlayListController, player: Player) { 32 | self.playList = playList 33 | self.player = player 34 | } 35 | 36 | var display: Media { 37 | currentMedia ?? .placeholder 38 | } 39 | 40 | var title: String { 41 | display.title 42 | } 43 | 44 | var subtitle: String? { 45 | display.subtitle 46 | } 47 | 48 | var playPauseButton: ButtonType { 49 | switch state { 50 | case .playing: currentMedia.map(\.online) ?? false ? .stop : .pause 51 | case .paused: .play 52 | } 53 | } 54 | 55 | var backwardButton: ButtonType { .backward } 56 | var forwardButton: ButtonType { .forward } 57 | 58 | func onAppear() { 59 | updateColors() 60 | } 61 | 62 | func onPlayPause() { 63 | enshureMediaAvailable() 64 | guard let currentMedia else { return } 65 | state.toggle() 66 | if state == .playing { 67 | player.play(currentMedia) 68 | } else { 69 | player.stop() 70 | } 71 | } 72 | 73 | func onForward() { 74 | enshureMediaAvailable() 75 | guard currentMedia != nil else { return } 76 | 77 | guard let currentIndex else { 78 | self.currentIndex = 0 79 | return 80 | } 81 | 82 | var next = currentIndex + 1 83 | if next >= playList.items.count { 84 | next = 0 85 | } 86 | self.currentIndex = next 87 | updateColors() 88 | } 89 | 90 | func onBackward() { 91 | enshureMediaAvailable() 92 | guard currentMedia != nil else { return } 93 | 94 | let lastIndex = playList.items.count - 1 95 | guard let currentIndex else { 96 | self.currentIndex = lastIndex 97 | return 98 | } 99 | 100 | var prev = currentIndex - 1 101 | if prev < 0 { 102 | prev = lastIndex 103 | } 104 | self.currentIndex = prev 105 | updateColors() 106 | } 107 | } 108 | 109 | private extension NowPlayingController { 110 | func enshureMediaAvailable() { 111 | if playList.items.isEmpty { 112 | selectFirstAvailableMedia() 113 | } 114 | } 115 | 116 | func selectFirstAvailableMedia() { 117 | stopPlaying() 118 | playList.selectFirstAvailable() 119 | currentIndex = playList.items.isEmpty ? nil : 0 120 | } 121 | 122 | func stopPlaying() { 123 | state = .paused 124 | player.stop() 125 | } 126 | 127 | func updateColors() { 128 | guard let url = display.artwork else { return } 129 | Task { @MainActor in 130 | if let image = try? await KingfisherManager.shared.retrieveImage(with: url).image { 131 | self.colors = (image.dominantColorFrequencies(with: .high) ?? []) 132 | } 133 | } 134 | } 135 | } 136 | 137 | private extension NowPlayingController.State { 138 | mutating func toggle() { 139 | switch self { 140 | case .playing: self = .paused 141 | case .paused: self = .playing 142 | } 143 | } 144 | } 145 | 146 | private extension Media { 147 | static var placeholder: Self { 148 | Media( 149 | artwork: nil, 150 | title: "---", 151 | subtitle: "---", 152 | online: false 153 | ) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/OverlaidRootView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OverlaidRootView.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 17.11.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OverlaidRootView: View { 11 | @State private var playlistController: PlayListController 12 | @State private var playerController: NowPlayingController 13 | @State private var nowPlayingExpandProgress: CGFloat = .zero 14 | @State private var showOverlayingNowPlayng: Bool = true 15 | @State private var expandedNowPlaying: Bool = false 16 | @State private var showNowPlayingReplacement: Bool = false 17 | 18 | init() { 19 | let playlistController = PlayListController() 20 | playerController = NowPlayingController( 21 | playList: playlistController, 22 | player: Player() 23 | ) 24 | self.playlistController = playlistController 25 | } 26 | 27 | var body: some View { 28 | ZStack(alignment: .bottom) { 29 | RootView() 30 | CompactNowPlayingReplacement(expanded: .constant(false)) 31 | .opacity(showNowPlayingReplacement ? 1 : 0) 32 | } 33 | .environment(playerController) 34 | .environment(playlistController) 35 | .universalOverlay(animation: .none, show: $showOverlayingNowPlayng) { 36 | ExpandableNowPlaying( 37 | show: $showOverlayingNowPlayng, 38 | expanded: $expandedNowPlaying 39 | ) 40 | .environment(playerController) 41 | .onPreferenceChange(NowPlayingExpandProgressPreferenceKey.self) { value in 42 | nowPlayingExpandProgress = value 43 | } 44 | } 45 | .environment(\.nowPlayingExpandProgress, nowPlayingExpandProgress) 46 | } 47 | 48 | func showNowPlayng(replacement: Bool) { 49 | guard !expandedNowPlaying else { return } 50 | showOverlayingNowPlayng = !replacement 51 | showNowPlayingReplacement = replacement 52 | } 53 | } 54 | 55 | private struct CompactNowPlayingReplacement: View { 56 | @Namespace private var animationNamespaceStub 57 | @Binding var expanded: Bool 58 | var body: some View { 59 | ZStack(alignment: .top) { 60 | NowPlayingBackground( 61 | colors: [], 62 | expanded: false, 63 | isFullExpanded: false, 64 | canBeExpanded: false 65 | ) 66 | CompactNowPlaying( 67 | expanded: $expanded, 68 | hideArtworkOnExpanded: false, 69 | animationNamespace: animationNamespaceStub 70 | ) 71 | } 72 | .padding(.horizontal, 12) 73 | .padding(.bottom, ViewConst.compactNowPlayingHeight) 74 | } 75 | } 76 | 77 | #Preview { 78 | OverlayableRootView { 79 | OverlaidRootView() 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/Player/Player.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Player.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 30.11.2024. 6 | // 7 | 8 | import Foundation 9 | 10 | class Player { 11 | func play(_ media: Media) { 12 | print("Play \(media.title)") 13 | } 14 | 15 | func stop() { 16 | print("Stop") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/RootView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootView.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 27.11.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RootView: View { 11 | @State private var tabSelection: TabBarItem = .home 12 | 13 | var body: some View { 14 | CustomTabView(selection: $tabSelection) { 15 | MediaListView() 16 | .tabBarItem(tab: .home, selection: $tabSelection) 17 | 18 | Text("Looking for something?") 19 | .tabBarItem(tab: .search, selection: $tabSelection) 20 | } 21 | } 22 | } 23 | 24 | #Preview { 25 | RootView() 26 | .environment(PlayListController()) 27 | } 28 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Components/Background/ColorPoint.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorPoint.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 09.09.2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ColorPoint: Hashable { 11 | var position: UnitPoint 12 | var color: Color 13 | } 14 | 15 | extension ColorPoint: CustomStringConvertible { 16 | var description: String { 17 | String(format: "Point(x=%.3f,y=%.3f,c=\(UIColor(color).hex))", position.x, position.y) 18 | } 19 | } 20 | 21 | extension ColorPoint: Animatable { 22 | typealias AnimatableData = AnimatablePair 23 | 24 | var animatableData: ColorPoint.AnimatableData { 25 | get { 26 | ColorPoint.AnimatableData( 27 | position.animatableData, 28 | color.resolve(in: .init()).animatableData 29 | ) 30 | } 31 | set { 32 | position = UnitPoint(newValue.first) 33 | color = Color(newValue.second) 34 | } 35 | } 36 | } 37 | 38 | private extension ColorPoint { 39 | static var zero: ColorPoint { 40 | ColorPoint(position: .zero, color: .black.opacity(0)) 41 | } 42 | 43 | init(_ animatableData: ColorPoint.AnimatableData) { 44 | self.init( 45 | position: UnitPoint(animatableData.first), 46 | color: Color(animatableData.second) 47 | ) 48 | } 49 | } 50 | 51 | private extension Color { 52 | init(_ animatableData: Color.Resolved.AnimatableData) { 53 | var resolvedColor = Color.Resolved(red: 0, green: 0, blue: 0) 54 | resolvedColor.animatableData = animatableData 55 | self.init(resolvedColor) 56 | } 57 | } 58 | 59 | private extension UnitPoint { 60 | static let animatableDataRatio = 61 | UnitPoint(x: 1, y: 1).animatableData.first / UnitPoint(x: 1, y: 1).x 62 | 63 | init(_ animatableData: UnitPoint.AnimatableData) { 64 | self.init( 65 | x: animatableData.first / UnitPoint.animatableDataRatio, 66 | y: animatableData.second / UnitPoint.animatableDataRatio 67 | ) 68 | } 69 | } 70 | 71 | struct ColorPoints { 72 | let point0: ColorPoint 73 | let point1: ColorPoint 74 | let point2: ColorPoint 75 | let point3: ColorPoint 76 | let point4: ColorPoint 77 | let point5: ColorPoint 78 | let point6: ColorPoint 79 | let point7: ColorPoint 80 | } 81 | 82 | struct ColorPointsAnimatableData { 83 | var value0: ColorPoint.AnimatableData 84 | var value1: ColorPoint.AnimatableData 85 | var value2: ColorPoint.AnimatableData 86 | var value3: ColorPoint.AnimatableData 87 | var value4: ColorPoint.AnimatableData 88 | var value5: ColorPoint.AnimatableData 89 | var value6: ColorPoint.AnimatableData 90 | var value7: ColorPoint.AnimatableData 91 | } 92 | 93 | extension ColorPoints { 94 | init(points: [ColorPoint]) { 95 | self.init( 96 | point0: points[safe: 0] ?? ColorPoint(position: .zero, color: .black.opacity(0)), 97 | point1: points[safe: 1] ?? ColorPoint(position: .zero, color: .black.opacity(0)), 98 | point2: points[safe: 2] ?? ColorPoint(position: .zero, color: .black.opacity(0)), 99 | point3: points[safe: 3] ?? ColorPoint(position: .zero, color: .black.opacity(0)), 100 | point4: points[safe: 4] ?? ColorPoint(position: .zero, color: .black.opacity(0)), 101 | point5: points[safe: 5] ?? ColorPoint(position: .zero, color: .black.opacity(0)), 102 | point6: points[safe: 6] ?? ColorPoint(position: .zero, color: .black.opacity(0)), 103 | point7: points[safe: 7] ?? ColorPoint(position: .zero, color: .black.opacity(0)) 104 | ) 105 | } 106 | 107 | init(_ animatableData: ColorPointsAnimatableData) { 108 | self.init( 109 | point0: ColorPoint(animatableData.value0), 110 | point1: ColorPoint(animatableData.value1), 111 | point2: ColorPoint(animatableData.value2), 112 | point3: ColorPoint(animatableData.value3), 113 | point4: ColorPoint(animatableData.value4), 114 | point5: ColorPoint(animatableData.value5), 115 | point6: ColorPoint(animatableData.value6), 116 | point7: ColorPoint(animatableData.value7) 117 | ) 118 | } 119 | 120 | static var zero: ColorPoints { 121 | ColorPoints( 122 | point0: .zero, 123 | point1: .zero, 124 | point2: .zero, 125 | point3: .zero, 126 | point4: .zero, 127 | point5: .zero, 128 | point6: .zero, 129 | point7: .zero 130 | ) 131 | } 132 | 133 | var shuffled: ColorPoints { 134 | ColorPoints( 135 | point0: .random(withColor: point0.color), 136 | point1: .random(withColor: point1.color), 137 | point2: .random(withColor: point2.color), 138 | point3: .random(withColor: point3.color), 139 | point4: .random(withColor: point4.color), 140 | point5: .random(withColor: point5.color), 141 | point6: .random(withColor: point6.color), 142 | point7: .random(withColor: point7.color) 143 | ) 144 | } 145 | 146 | func colored(colors: [Color]) -> ColorPoints { 147 | ColorPoints( 148 | point0: ColorPoint(position: point0.position, color: colors[safe: 0] ?? .black.opacity(0)), 149 | point1: ColorPoint(position: point1.position, color: colors[safe: 1] ?? .black.opacity(0)), 150 | point2: ColorPoint(position: point2.position, color: colors[safe: 2] ?? .black.opacity(0)), 151 | point3: ColorPoint(position: point3.position, color: colors[safe: 3] ?? .black.opacity(0)), 152 | point4: ColorPoint(position: point4.position, color: colors[safe: 4] ?? .black.opacity(0)), 153 | point5: ColorPoint(position: point5.position, color: colors[safe: 5] ?? .black.opacity(0)), 154 | point6: ColorPoint(position: point6.position, color: colors[safe: 6] ?? .black.opacity(0)), 155 | point7: ColorPoint(position: point7.position, color: colors[safe: 7] ?? .black.opacity(0)) 156 | ) 157 | } 158 | } 159 | 160 | extension ColorPoints: Animatable { 161 | var animatableData: ColorPointsAnimatableData { 162 | get { ColorPointsAnimatableData(self) } 163 | set { self = ColorPoints(newValue) } 164 | } 165 | } 166 | 167 | extension ColorPointsAnimatableData { 168 | init(_ colorPoints: ColorPoints) { 169 | self.init( 170 | value0: colorPoints.point0.animatableData, 171 | value1: colorPoints.point1.animatableData, 172 | value2: colorPoints.point2.animatableData, 173 | value3: colorPoints.point3.animatableData, 174 | value4: colorPoints.point4.animatableData, 175 | value5: colorPoints.point5.animatableData, 176 | value6: colorPoints.point6.animatableData, 177 | value7: colorPoints.point7.animatableData 178 | ) 179 | } 180 | } 181 | 182 | extension ColorPointsAnimatableData: VectorArithmetic { 183 | static func - (lhs: ColorPointsAnimatableData, rhs: ColorPointsAnimatableData) -> ColorPointsAnimatableData { 184 | ColorPointsAnimatableData( 185 | value0: lhs.value0 - rhs.value0, 186 | value1: lhs.value1 - rhs.value1, 187 | value2: lhs.value2 - rhs.value2, 188 | value3: lhs.value3 - rhs.value3, 189 | value4: lhs.value4 - rhs.value4, 190 | value5: lhs.value5 - rhs.value5, 191 | value6: lhs.value6 - rhs.value6, 192 | value7: lhs.value7 - rhs.value7 193 | ) 194 | } 195 | 196 | static func + (lhs: ColorPointsAnimatableData, rhs: ColorPointsAnimatableData) -> ColorPointsAnimatableData { 197 | ColorPointsAnimatableData( 198 | value0: lhs.value0 + rhs.value0, 199 | value1: lhs.value1 + rhs.value1, 200 | value2: lhs.value2 + rhs.value2, 201 | value3: lhs.value3 + rhs.value3, 202 | value4: lhs.value4 + rhs.value4, 203 | value5: lhs.value5 + rhs.value5, 204 | value6: lhs.value6 + rhs.value6, 205 | value7: lhs.value7 + rhs.value7 206 | ) 207 | } 208 | 209 | mutating func scale(by rhs: Double) { 210 | value0 = value0.scaled(by: rhs) 211 | value1 = value1.scaled(by: rhs) 212 | value2 = value2.scaled(by: rhs) 213 | value3 = value3.scaled(by: rhs) 214 | value4 = value4.scaled(by: rhs) 215 | value5 = value5.scaled(by: rhs) 216 | value6 = value6.scaled(by: rhs) 217 | value7 = value7.scaled(by: rhs) 218 | } 219 | 220 | var magnitudeSquared: Double { 221 | value0.magnitudeSquared + 222 | value1.magnitudeSquared + 223 | value2.magnitudeSquared + 224 | value3.magnitudeSquared + 225 | value4.magnitudeSquared + 226 | value5.magnitudeSquared + 227 | value6.magnitudeSquared + 228 | value7.magnitudeSquared 229 | } 230 | 231 | static var zero: ColorPointsAnimatableData { 232 | ColorPointsAnimatableData( 233 | value0: .zero, 234 | value1: .zero, 235 | value2: .zero, 236 | value3: .zero, 237 | value4: .zero, 238 | value5: .zero, 239 | value6: .zero, 240 | value7: .zero 241 | ) 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Components/Background/ColorfulBackground.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorfulBackground.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 20.09.2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ColorfulBackground: View { 11 | @State var model = ColorfulBackgroundModel() 12 | let colors: [Color] 13 | 14 | var body: some View { 15 | MulticolorGradient( 16 | points: model.points, 17 | animationUpdateHandler: { [weak model] newPoints in 18 | Task { @MainActor in 19 | model?.onUpdate(animatedData: newPoints) 20 | } 21 | } 22 | ) 23 | .onAppear { 24 | model.set(colors) 25 | model.onAppear() 26 | } 27 | .onChange(of: colors) { 28 | model.set(colors) 29 | } 30 | } 31 | } 32 | 33 | #Preview { 34 | ColorfulBackground(colors: [.pink, .indigo, .cyan]) 35 | .ignoresSafeArea() 36 | } 37 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Components/Background/ColorfulBackgroundModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorfulBackgroundModel.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 24.09.2023. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | 11 | @MainActor 12 | @Observable 13 | class ColorfulBackgroundModel: @unchecked Sendable { 14 | static let animationDuration: Double = 20 15 | var points: ColorPoints = .zero.shuffled 16 | 17 | private var colors: [Color] = [] 18 | private var shown = false 19 | private var animatedData: ColorPoints = .zero 20 | private var animatioTimerCancellable: AnyCancellable? 21 | 22 | func onAppear() { 23 | shown = true 24 | animate() 25 | animatedData = points 26 | 27 | animatioTimerCancellable = Timer 28 | .publish(every: Self.animationDuration * 0.9, on: .main, in: .common) 29 | .autoconnect() 30 | .sink { [weak self] _ in 31 | self?.animate() 32 | } 33 | } 34 | 35 | func onUpdate(animatedData: ColorPoints) { 36 | self.animatedData = animatedData 37 | } 38 | 39 | func set(_ colors: [Color]) { 40 | guard colors != self.colors else { return } 41 | self.colors = colors 42 | if shown { 43 | withAnimation { 44 | points = animatedData.colored(colors: colors) 45 | } 46 | } else { 47 | points = animatedData.colored(colors: colors) 48 | } 49 | } 50 | 51 | func animate() { 52 | withAnimation(.linear(duration: Self.animationDuration)) { 53 | points = points.shuffled 54 | } 55 | } 56 | } 57 | 58 | extension ColorPoint { 59 | static func random(withColor color: Color) -> ColorPoint { 60 | ColorPoint( 61 | position: UnitPoint(x: CGFloat.random(in: 0 ..< 1), y: CGFloat.random(in: 0 ..< 1)), 62 | color: color 63 | ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Components/Background/MulticolorGradient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MulticolorGradient.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 09.09.2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MulticolorGradient: View, Animatable { 11 | var points: ColorPoints 12 | var animationUpdateHandler: (@Sendable (ColorPoints) -> Void)? 13 | 14 | var uniforms: Uniforms { 15 | Uniforms(params: GradientParams(points: points, bias: 0.05, power: 2.5, noise: 2)) 16 | } 17 | 18 | nonisolated var animatableData: ColorPoints.AnimatableData { 19 | get { 20 | points.animatableData 21 | } 22 | set { 23 | let newPoints = ColorPoints(newValue) 24 | points = newPoints 25 | let viewCopy = self 26 | Task { @MainActor in 27 | viewCopy.animationUpdateHandler?(newPoints) 28 | } 29 | } 30 | } 31 | 32 | var body: some View { 33 | Rectangle() 34 | .colorEffect(ShaderLibrary.gradient(.boundingRect, .uniforms(uniforms))) 35 | } 36 | } 37 | 38 | @MainActor 39 | private extension MulticolorGradient { 40 | mutating func updatePoints(newPoints: ColorPoints) { 41 | points = newPoints 42 | animationUpdateHandler?(newPoints) 43 | } 44 | } 45 | 46 | extension Shader.Argument { 47 | static func uniforms(_ param: Uniforms) -> Shader.Argument { 48 | var copy = param 49 | return .data(Data(bytes: ©, count: MemoryLayout.stride)) 50 | } 51 | } 52 | 53 | #Preview { 54 | MulticolorGradient( 55 | points: ColorPoints( 56 | points: [ 57 | ColorPoint(position: .top, color: .pink), 58 | ColorPoint(position: .leading, color: .indigo), 59 | ColorPoint(position: .bottomTrailing, color: .cyan) 60 | ] 61 | ) 62 | ) 63 | .ignoresSafeArea() 64 | } 65 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Components/Background/MulticolorGradientShader.metal: -------------------------------------------------------------------------------- 1 | // 2 | // MulticolorGradientShader.metal 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 09.09.2023. 6 | // 7 | 8 | #include 9 | using namespace metal; 10 | 11 | typedef struct { 12 | int32_t pointCount; 13 | float bias; 14 | float power; 15 | float noise; 16 | float2 points[8]; 17 | float4 colors[8]; 18 | } Uniforms; 19 | 20 | float2 hash23(float3 p3) { 21 | p3 = fract(p3 * float3(443.897, 441.423, .0973)); 22 | p3 += dot(p3, p3.yzx + 19.19); 23 | return fract((p3.xx + p3.yz) * p3.zy); 24 | } 25 | 26 | 27 | [[ stitchable ]] half4 gradient( 28 | float2 position, 29 | half4 currentColor, 30 | float4 box, 31 | constant Uniforms& uniforms [[buffer(0)]], 32 | int size_in_bytes 33 | ) { 34 | float2 size = box.zw; 35 | float2 noise = hash23(float3(position / float2(size.x, size.x), 0)); 36 | float2 uv = (position + float2(sin(noise.x * 2 * M_PI_F), sin(noise.y * 2 * M_PI_F)) * uniforms.noise) / float2(size.x, size.x); 37 | 38 | float totalContribution = 0.0; 39 | float contribution[8]; 40 | 41 | // Compute contributions 42 | for (int i = 0; i < uniforms.pointCount; i++) { 43 | float2 pos = uniforms.points[i] * float2(1.0, float(size.y) / float(size.x)); 44 | pos = uv - pos; 45 | float dist = length(pos); 46 | float c = 1.0 / (uniforms.bias + pow(dist, uniforms.power)) * uniforms.colors[i].a ; 47 | contribution[i] = c; 48 | totalContribution += c; 49 | } 50 | 51 | // Contributions normalisation 52 | float3 col = float3(0, 0, 0); 53 | float inverseContribution = 1.0 / totalContribution; 54 | for (int i = 0; i < uniforms.pointCount; i++) { 55 | col += contribution[i] * inverseContribution * uniforms.colors[i].rgb; 56 | } 57 | return half4(half3(col), 1.0); 58 | } 59 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Components/Background/Uniforms.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Uniforms.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 09.09.2023. 6 | // 7 | 8 | import simd 9 | import SwiftUI 10 | 11 | struct Uniforms { 12 | let pointCount: simd_int1 13 | 14 | let bias: simd_float1 15 | let power: simd_float1 16 | let noise: simd_float1 17 | 18 | let point0: simd_float2 19 | let point1: simd_float2 20 | let point2: simd_float2 21 | let point3: simd_float2 22 | let point4: simd_float2 23 | let point5: simd_float2 24 | let point6: simd_float2 25 | let point7: simd_float2 26 | 27 | let color0: simd_float4 28 | let color1: simd_float4 29 | let color2: simd_float4 30 | let color3: simd_float4 31 | let color4: simd_float4 32 | let color5: simd_float4 33 | let color6: simd_float4 34 | let color7: simd_float4 35 | } 36 | 37 | struct GradientParams { 38 | let points: ColorPoints 39 | let bias: Float 40 | let power: Float 41 | let noise: Float 42 | } 43 | 44 | extension Uniforms { 45 | init(params: GradientParams) { 46 | self.init( 47 | pointCount: 8, 48 | bias: params.bias, 49 | power: params.power, 50 | noise: params.noise, 51 | point0: params.points.point0.position.simd, 52 | point1: params.points.point1.position.simd, 53 | point2: params.points.point2.position.simd, 54 | point3: params.points.point3.position.simd, 55 | point4: params.points.point4.position.simd, 56 | point5: params.points.point5.position.simd, 57 | point6: params.points.point6.position.simd, 58 | point7: params.points.point7.position.simd, 59 | color0: params.points.point0.color.simd, 60 | color1: params.points.point1.color.simd, 61 | color2: params.points.point2.color.simd, 62 | color3: params.points.point3.color.simd, 63 | color4: params.points.point4.color.simd, 64 | color5: params.points.point5.color.simd, 65 | color6: params.points.point6.color.simd, 66 | color7: params.points.point7.color.simd 67 | ) 68 | } 69 | } 70 | 71 | extension UnitPoint { 72 | var simd: simd_float2 { simd_float2(Float(x), Float(y)) } 73 | } 74 | 75 | extension Color { 76 | var simd: simd_float4 { 77 | var red: CGFloat = 0 78 | var green: CGFloat = 0 79 | var blue: CGFloat = 0 80 | var alpha: CGFloat = 0 81 | UIColor(self).getRed(&red, green: &green, blue: &blue, alpha: &alpha) 82 | return simd_float4(Float(red), Float(green), Float(blue), Float(alpha)) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Components/BlurView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BlurView.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 07.01.2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BlurView: UIViewRepresentable { 11 | let effect: UIVisualEffect 12 | let intensity: CGFloat 13 | 14 | init(effect: UIVisualEffect, intensity: CGFloat = 1) { 15 | self.effect = effect 16 | self.intensity = intensity 17 | } 18 | 19 | init(style: UIBlurEffect.Style, intensity: CGFloat = 1) { 20 | self.init(effect: UIBlurEffect(style: style), intensity: intensity) 21 | } 22 | 23 | func makeUIView(context _: Context) -> UIVisualEffectView { 24 | CustomVisualEffectView(effect: effect, intensity: intensity) 25 | } 26 | 27 | func updateUIView(_ uiView: UIVisualEffectView, context _: Context) { 28 | uiView.effect = effect 29 | } 30 | } 31 | 32 | final class CustomVisualEffectView: UIVisualEffectView { 33 | private let theEffect: UIVisualEffect 34 | private let intensity: CGFloat 35 | @MainActor private var animator: UIViewPropertyAnimator? 36 | 37 | init(effect: UIVisualEffect, intensity: CGFloat) { 38 | theEffect = effect 39 | self.intensity = intensity 40 | super.init(effect: nil) 41 | } 42 | 43 | required init?(coder _: NSCoder) { nil } 44 | 45 | deinit { 46 | let animator = self.animator 47 | self.animator = nil 48 | Task { @MainActor in 49 | animator?.stopAnimation(true) 50 | } 51 | } 52 | 53 | @MainActor 54 | override public func draw(_ rect: CGRect) { 55 | super.draw(rect) 56 | effect = nil 57 | animator?.stopAnimation(true) 58 | animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [unowned self] in 59 | self.effect = theEffect 60 | } 61 | animator?.fractionComplete = intensity 62 | } 63 | } 64 | 65 | #Preview { 66 | BlurView(style: .systemThickMaterial) 67 | .ignoresSafeArea() 68 | } 69 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Components/ElasticSlider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ElasticSlider.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 13.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ElasticSlider: View { 11 | @Binding private var value: Double 12 | private let range: ClosedRange 13 | private let leadingLabel: LeadingContent? 14 | private let trailingLabel: TrailingContent? 15 | @Environment(\.elasticSliderConfig) var config 16 | @State private var lastStoredValue: CGFloat 17 | @State private var stretchingValue: CGFloat = 0 18 | @State private var viewSize: CGSize = .zero 19 | @GestureState private var isActive: Bool = false 20 | 21 | init( 22 | value: Binding, 23 | in range: ClosedRange, 24 | leadingLabel: (() -> LeadingContent)? = nil, 25 | trailingLabel: (() -> TrailingContent)? = nil 26 | ) { 27 | _value = value 28 | self.range = range 29 | lastStoredValue = value.wrappedValue 30 | self.leadingLabel = leadingLabel?() 31 | self.trailingLabel = trailingLabel?() 32 | } 33 | 34 | var body: some View { 35 | Group { 36 | if config.labelLocation == .bottom { 37 | bottomLabeledTrack 38 | } else { 39 | sideLabeledTrack 40 | } 41 | } 42 | .animation(.smooth(duration: 0.3, extraBounce: 0.3), value: isActive) 43 | .sensoryFeedback(.increase, trigger: isValueExtreme) { config.defaultSensoryFeedback && $1 } 44 | } 45 | } 46 | 47 | // MARK: private 48 | 49 | private extension ElasticSlider { 50 | var isValueExtreme: Bool { 51 | value == range.lowerBound || value == range.upperBound 52 | } 53 | 54 | @ViewBuilder 55 | func styled(_ content: Content) -> some View { 56 | if config.syncLabelsStyle { 57 | ZStack { 58 | content 59 | .foregroundStyle(config.maximumTrackColor) 60 | .blendMode(config.blendMode) 61 | content 62 | .foregroundStyle(isActive ? config.minimumTrackActiveColor : config.minimumTrackInactiveColor) 63 | } 64 | .animation(nil, value: isActive) 65 | .blendMode(isActive ? .normal : config.blendMode) 66 | .transformEffect(.identity) 67 | } else { 68 | content 69 | } 70 | } 71 | 72 | var bottomLabeledTrack: some View { 73 | VStack(spacing: 0) { 74 | track 75 | HStack(spacing: 0) { 76 | let padding = (isActive ? 0 : config.growth) + config.maxStretch 77 | styled(leadingLabel) 78 | .padding(.leading, padding - leadingStretch) 79 | 80 | Spacer() 81 | styled(trailingLabel) 82 | .padding(.trailing, padding - trailingStretch) 83 | } 84 | } 85 | } 86 | 87 | var sideLabeledTrack: some View { 88 | HStack(spacing: 0) { 89 | let padding = (isActive ? 0 : config.growth) + config.maxStretch 90 | styled(leadingLabel) 91 | .offset(x: padding - leadingStretch) 92 | track 93 | 94 | styled(trailingLabel) 95 | .offset(x: trailingStretch - padding) 96 | } 97 | } 98 | 99 | var track: some View { 100 | GeometryReader { proxy in 101 | let size = proxy.size 102 | ZStack { 103 | Capsule() 104 | .fill(config.maximumTrackColor) 105 | .blendMode(config.blendMode) 106 | 107 | let fillWidth = max(0, normalized(value) 108 | * trackWidth(for: size.width, active: isActive) 109 | - leadingStretch 110 | + trailingStretch) 111 | Capsule() 112 | .fill(isActive ? config.minimumTrackActiveColor : config.minimumTrackInactiveColor) 113 | .blendMode(isActive ? .normal : config.blendMode) 114 | .mask( 115 | Rectangle() 116 | .frame(width: fillWidth) 117 | .frame(maxWidth: .infinity, alignment: .leading) 118 | ) 119 | } 120 | .preference(key: SizePreferenceKey.self, value: size) 121 | .frame( 122 | height: isActive 123 | ? config.activeHeight - abs(normalizedStretchingValue) * config.stretchNarrowing 124 | : config.inactiveHeight 125 | ) 126 | .padding(.horizontal, isActive ? 0 : config.growth) 127 | .padding(.leading, config.maxStretch - leadingStretch) 128 | .padding(.trailing, config.maxStretch - trailingStretch) 129 | .onPreferenceChange(SizePreferenceKey.self) { value in 130 | viewSize = value 131 | } 132 | .highPriorityGesture( 133 | DragGesture(minimumDistance: 0) 134 | .updating($isActive) { _, out, _ in 135 | out = true 136 | } 137 | .onChanged { value in 138 | let progress = (value.translation.width / trackWidth(for: size.width, active: true)) 139 | * range.distance 140 | + lastStoredValue 141 | self.value = Double(progress).clamped(to: range) 142 | if progress < range.lowerBound { 143 | stretchingValue = normalized(progress - range.lowerBound) 144 | } 145 | if progress > range.upperBound { 146 | stretchingValue = normalized(progress - range.upperBound) 147 | } 148 | } 149 | .onEnded { _ in 150 | lastStoredValue = value 151 | stretchingValue = 0 152 | } 153 | ) 154 | } 155 | .frame( 156 | height: max(0, isActive 157 | ? config.activeHeight - abs(normalizedStretchingValue) * config.stretchNarrowing 158 | : config.inactiveHeight) 159 | ) 160 | } 161 | 162 | var normalizedStretchingValue: CGFloat { 163 | guard config.maxStretch != 0 else { return 0 } 164 | let trackWidth = activeTrackWidth 165 | guard trackWidth != 0, viewSize.width > config.maxStretch * 2 else { return 0 } 166 | let max = config.maxStretch / trackWidth / config.pushStretchRatio 167 | return stretchingValue.clamped(to: -max ... max) / max 168 | } 169 | 170 | var leadingStretch: CGFloat { 171 | let value = normalizedStretchingValue 172 | let stretch = abs(value) * config.maxStretch 173 | return value < 0 ? stretch : -stretch * config.pullStretchRatio 174 | } 175 | 176 | var trailingStretch: CGFloat { 177 | let value = normalizedStretchingValue 178 | let stretch = abs(value) * config.maxStretch 179 | return stretchingValue > 0 ? stretch : -stretch * config.pullStretchRatio 180 | } 181 | 182 | func normalized(_ value: CGFloat) -> CGFloat { 183 | (value - range.lowerBound) / range.distance 184 | } 185 | 186 | var activeTrackWidth: CGFloat { 187 | trackWidth(for: viewSize.width, active: true) 188 | } 189 | 190 | func trackWidth(for viewWidth: CGFloat, active: Bool) -> CGFloat { 191 | max(0, viewWidth - config.maxStretch * 2 - (active ? 0 : config.growth * 2)) 192 | } 193 | } 194 | 195 | // MARK: Convenience initializers 196 | 197 | extension ElasticSlider where LeadingContent == EmptyView { 198 | init( 199 | value: Binding, 200 | in range: ClosedRange, 201 | trailingLabel: (() -> TrailingContent)? = nil 202 | ) { 203 | _value = value 204 | self.range = range 205 | lastStoredValue = value.wrappedValue 206 | leadingLabel = nil 207 | self.trailingLabel = trailingLabel?() 208 | } 209 | } 210 | 211 | extension ElasticSlider where TrailingContent == EmptyView { 212 | init( 213 | value: Binding, 214 | in range: ClosedRange, 215 | config _: ElasticSliderConfig = .init(), 216 | leadingLabel: (() -> LeadingContent)? = nil 217 | ) { 218 | _value = value 219 | self.range = range 220 | lastStoredValue = value.wrappedValue 221 | self.leadingLabel = leadingLabel?() 222 | trailingLabel = nil 223 | } 224 | } 225 | 226 | extension ElasticSlider where LeadingContent == EmptyView, TrailingContent == EmptyView { 227 | init( 228 | value: Binding, 229 | in range: ClosedRange, 230 | config _: ElasticSliderConfig = .init() 231 | ) { 232 | _value = value 233 | self.range = range 234 | lastStoredValue = value.wrappedValue 235 | leadingLabel = nil 236 | trailingLabel = nil 237 | } 238 | } 239 | 240 | // MARK: config 241 | 242 | struct ElasticSliderConfig { 243 | enum LabelLocation { 244 | case bottom 245 | case side 246 | } 247 | 248 | let labelLocation: LabelLocation 249 | let activeHeight: CGFloat 250 | let inactiveHeight: CGFloat 251 | let growth: CGFloat 252 | let stretchNarrowing: CGFloat 253 | let maxStretch: CGFloat 254 | let pushStretchRatio: CGFloat 255 | let pullStretchRatio: CGFloat 256 | let minimumTrackActiveColor: Color 257 | let minimumTrackInactiveColor: Color 258 | let maximumTrackColor: Color 259 | let blendMode: BlendMode 260 | let syncLabelsStyle: Bool 261 | let defaultSensoryFeedback: Bool 262 | 263 | init( 264 | labelLocation: ElasticSliderConfig.LabelLocation = .side, 265 | activeHeight: CGFloat = 17, 266 | inactiveHeight: CGFloat = 7, 267 | growth: CGFloat = 9, 268 | stretchNarrowing: CGFloat = 4, 269 | maxStretch: CGFloat = 9, 270 | pushStretchRatio: CGFloat = 0.2, 271 | pullStretchRatio: CGFloat = 0.5, 272 | minimumTrackActiveColor: Color = .init(UIColor.tintColor), 273 | minimumTrackInactiveColor: Color = .init(UIColor.systemGray3), 274 | maximumTrackColor: Color = .init(UIColor.systemGray6), 275 | blendMode: BlendMode = .normal, 276 | syncLabelsStyle: Bool = false, 277 | defaultSensoryFeedback: Bool = true 278 | ) { 279 | self.labelLocation = labelLocation 280 | self.activeHeight = activeHeight 281 | self.inactiveHeight = inactiveHeight 282 | self.growth = growth 283 | self.stretchNarrowing = stretchNarrowing 284 | self.maxStretch = maxStretch 285 | self.pushStretchRatio = pushStretchRatio 286 | self.pullStretchRatio = pullStretchRatio 287 | self.minimumTrackActiveColor = minimumTrackActiveColor 288 | self.minimumTrackInactiveColor = minimumTrackInactiveColor 289 | self.maximumTrackColor = maximumTrackColor 290 | self.blendMode = blendMode 291 | self.syncLabelsStyle = syncLabelsStyle 292 | self.defaultSensoryFeedback = defaultSensoryFeedback 293 | } 294 | } 295 | 296 | // MARK: EnvironmentValues 297 | 298 | extension View { 299 | func sliderStyle(_ config: ElasticSliderConfig) -> some View { 300 | environment(\.elasticSliderConfig, config) 301 | } 302 | } 303 | 304 | private struct ElasticSliderConfigEnvironmentKey: EnvironmentKey { 305 | static let defaultValue: ElasticSliderConfig = .init() 306 | } 307 | 308 | extension EnvironmentValues { 309 | var elasticSliderConfig: ElasticSliderConfig { 310 | get { self[ElasticSliderConfigEnvironmentKey.self] } 311 | set { self[ElasticSliderConfigEnvironmentKey.self] = newValue } 312 | } 313 | } 314 | 315 | #Preview { 316 | @Previewable @State var progress: Double = 0.5 317 | @Previewable @State var volume: Double = 0.5 318 | let range = 0.0 ... 2 319 | VStack(spacing: 50) { 320 | ElasticSlider( 321 | value: $progress, 322 | in: range, 323 | leadingLabel: { 324 | Text(progress, format: .number.precision(.fractionLength(2))) 325 | }, 326 | trailingLabel: { 327 | Text( 328 | (range.upperBound - progress) * -1.0, 329 | format: .number.precision(.fractionLength(2)) 330 | ) 331 | } 332 | ) 333 | .sliderStyle(.init(labelLocation: .bottom, maxStretch: 0)) 334 | .padding(.horizontal, 15) 335 | .frame(height: 50) 336 | 337 | ElasticSlider( 338 | value: $volume, 339 | in: 0 ... 1, 340 | leadingLabel: { 341 | Image(systemName: "speaker.fill") 342 | .padding(.trailing, 4) 343 | }, 344 | trailingLabel: { 345 | Image(systemName: "speaker.wave.3.fill") 346 | .padding(.leading, 4) 347 | } 348 | ) 349 | .sliderStyle(.init(labelLocation: .side, syncLabelsStyle: true)) 350 | .frame(height: 50) 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Components/MarqueeText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MarqueeText.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 03.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct MarqueeText: View { 11 | let text: String 12 | private var config: Config 13 | 14 | @State private var textSize: CGSize = .zero 15 | @State private var animate = false 16 | 17 | init(_ text: String, config: Config = .init()) { 18 | self.text = text 19 | self.config = config 20 | } 21 | 22 | var body: some View { 23 | GeometryReader { geo in 24 | let viewWidth = geo.size.width 25 | let animatedTextVisible = textSize.width > viewWidth 26 | ZStack { 27 | animatedText(viewWidth: viewWidth) 28 | .hidden(!animatedTextVisible) 29 | 30 | staticText 31 | .hidden(animatedTextVisible) 32 | } 33 | } 34 | .frame(height: textSize.height) 35 | .overlay { 36 | Text(text) 37 | .padding(.leading, config.leftFade) 38 | .padding(.trailing, config.rightFade) 39 | .lineLimit(1) 40 | .fixedSize() 41 | .measureSize { textSize = $0 } 42 | .hidden() 43 | } 44 | .onAppear { 45 | withAnimation(animation) { 46 | animate = true 47 | } 48 | } 49 | } 50 | 51 | struct Config { 52 | var startDelay: Double = 1.0 53 | var alignment: Alignment = .leading 54 | var leftFade: CGFloat = 40 55 | var rightFade: CGFloat = 40 56 | var spacing: CGFloat = 100 57 | } 58 | } 59 | 60 | private extension MarqueeText { 61 | func animatedText(viewWidth: CGFloat) -> some View { 62 | Group { 63 | Text(text) 64 | .offset(x: -offset) 65 | Text(text) 66 | .offset(x: -offset + lineWidth) 67 | } 68 | .lineLimit(1) 69 | .fixedSize(horizontal: true, vertical: false) 70 | .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) 71 | .frame(width: viewWidth) 72 | .offset(x: config.leftFade) 73 | .mask(fadeMask) 74 | } 75 | 76 | var lineWidth: CGFloat { textSize.width - (config.leftFade + config.rightFade) + config.spacing } 77 | var offset: Double { animate ? lineWidth : 0 } 78 | 79 | var staticText: some View { 80 | Text(text) 81 | .padding(.leading, config.leftFade) 82 | .padding(.trailing, config.rightFade) 83 | .frame(minWidth: 0, maxWidth: .infinity, alignment: config.alignment) 84 | } 85 | 86 | var animation: Animation { 87 | .linear(duration: Double(textSize.width) / 30) 88 | .delay(config.startDelay) 89 | .repeatForever(autoreverses: false) 90 | } 91 | 92 | var fadeMask: some View { 93 | HStack(spacing: 0) { 94 | LinearGradient( 95 | gradient: Gradient(colors: [.black.opacity(0), .black]), 96 | startPoint: .leading, 97 | endPoint: .trailing 98 | ) 99 | .frame(width: config.leftFade) 100 | LinearGradient( 101 | gradient: Gradient(colors: [.black, .black]), 102 | startPoint: .leading, 103 | endPoint: .trailing 104 | ) 105 | LinearGradient( 106 | gradient: Gradient(colors: [.black, .black.opacity(0)]), 107 | startPoint: .leading, 108 | endPoint: .trailing 109 | ) 110 | .frame(width: config.rightFade) 111 | } 112 | .padding(.horizontal, 6) 113 | } 114 | } 115 | 116 | #Preview { 117 | HStack { 118 | MarqueeText( 119 | "A text that is way too long, but it scrolls", 120 | config: .init( 121 | startDelay: 3, 122 | leftFade: 32, 123 | rightFade: 32 124 | ) 125 | ) 126 | .background(.pink.opacity(0.6)) 127 | 128 | Text("Normal Text") 129 | .background(.mint.opacity(0.6)) 130 | } 131 | .padding(.horizontal, 16) 132 | .font(.largeTitle) 133 | } 134 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Components/PlayerButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlayerButton.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 18.12.2024. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | 11 | struct PlayerButton: View { 12 | @Environment(\.isEnabled) private var isEnabled 13 | @Environment(\.playerButtonConfig) var config 14 | @State private var showCircle = false 15 | @State private var pressed = false 16 | private let onPressed: (() -> Void)? 17 | private let onPressing: ((TimeInterval) -> Void)? 18 | private let onEnded: (() -> Void)? 19 | private let label: Content? 20 | 21 | init( 22 | label: (() -> Content)? = nil, 23 | onPressed: (() -> Void)? = nil, 24 | onPressing: ((TimeInterval) -> Void)? = nil, 25 | onEnded: (() -> Void)? = nil 26 | ) { 27 | self.label = label?() 28 | self.onPressed = onPressed 29 | self.onPressing = onPressing 30 | self.onEnded = onEnded 31 | } 32 | 33 | var body: some View { 34 | label 35 | .scaleEffect(pressed ? 0.9 : 1) 36 | .frame(width: config.size, height: config.size) 37 | .foregroundStyle(color) 38 | .background(showCircle ? config.tint : .clear) 39 | .clipShape(Ellipse()) 40 | .scaleEffect(pressed ? 0.85 : 1) 41 | .onPressGesture( 42 | interval: config.updateUnterval, 43 | onPressed: { 44 | guard isEnabled else { return } 45 | withAnimation { 46 | showCircle = true 47 | pressed = true 48 | } 49 | onPressed?() 50 | }, 51 | onPressing: { time in 52 | guard isEnabled else { return } 53 | onPressing?(time) 54 | }, 55 | onEnded: { 56 | guard isEnabled else { return } 57 | delay(0.2) { 58 | Task { @MainActor in 59 | withAnimation { 60 | showCircle = false 61 | } 62 | } 63 | } 64 | withAnimation { 65 | pressed = false 66 | } 67 | onEnded?() 68 | } 69 | ) 70 | .contentTransition(.symbolEffect(.replace)) 71 | } 72 | } 73 | 74 | private extension PlayerButton { 75 | var color: Color { 76 | isEnabled ? showCircle ? config.pressedColor : config.labelColor : config.disabledColor 77 | } 78 | } 79 | 80 | extension View { 81 | func playerButtonStyle(_ config: PlayerButtonConfig) -> some View { 82 | environment(\.playerButtonConfig, config) 83 | } 84 | } 85 | 86 | private struct PlayerButtonConfigEnvironmentKey: EnvironmentKey { 87 | static let defaultValue: PlayerButtonConfig = .init() 88 | } 89 | 90 | extension EnvironmentValues { 91 | var playerButtonConfig: PlayerButtonConfig { 92 | get { self[PlayerButtonConfigEnvironmentKey.self] } 93 | set { self[PlayerButtonConfigEnvironmentKey.self] = newValue } 94 | } 95 | } 96 | 97 | struct PlayerButtonConfig { 98 | let updateUnterval: TimeInterval 99 | let size: CGFloat 100 | let labelColor: Color 101 | let tint: Color 102 | let pressedColor: Color 103 | let disabledColor: Color 104 | 105 | init( 106 | updateUnterval: TimeInterval = 0.1, 107 | size: CGFloat = 68, 108 | labelColor: Color = .init(UIColor.label), 109 | tint: Color = .init(UIColor.tintColor), 110 | pressedColor: Color = .init(UIColor.secondaryLabel), 111 | disabledColor: Color = .init(UIColor.secondaryLabel) 112 | ) { 113 | self.updateUnterval = updateUnterval 114 | self.size = size 115 | self.labelColor = labelColor 116 | self.tint = tint 117 | self.pressedColor = pressedColor 118 | self.disabledColor = disabledColor 119 | } 120 | } 121 | 122 | extension PlayerButton where Content == EmptyView { 123 | init( 124 | onPressed: (() -> Void)? = nil, 125 | onPressing: ((TimeInterval) -> Void)? = nil, 126 | onEnded: (() -> Void)? = nil 127 | ) { 128 | label = nil 129 | self.onPressed = onPressed 130 | self.onPressing = onPressing 131 | self.onEnded = onEnded 132 | } 133 | } 134 | 135 | #Preview { 136 | struct ButtonPreview: View { 137 | var body: some View { 138 | PlayerButton( 139 | label: { 140 | Image(systemName: "play.fill") 141 | .resizable() 142 | .aspectRatio(contentMode: .fit) 143 | .frame(width: 34, height: 34) 144 | 145 | }, 146 | onPressed: { 147 | print("onPressed Button") 148 | }, 149 | onPressing: { time in 150 | print("onPressing \(time) Button") 151 | }, 152 | onEnded: { 153 | print("onEnded Button") 154 | } 155 | ) 156 | } 157 | } 158 | 159 | return HStack(spacing: 60) { 160 | VStack { 161 | ButtonPreview() 162 | .disabled(true) 163 | Text("Disabled") 164 | } 165 | 166 | VStack { 167 | ButtonPreview() 168 | Text("Enabled") 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Consts/AppFont.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppFont.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 26.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum AppFont { 11 | static let timingIndicator: Font = .system(size: 12, weight: .semibold) 12 | static let miniPlayerTitle: Font = .system(size: 15, weight: .medium) 13 | static let button: Font = .system(size: 17, weight: .semibold) 14 | static let mediaListHeaderTitle: Font = .system(size: 20, weight: .semibold) 15 | static let mediaListHeaderSubtitle: Font = .system(size: 20, weight: .regular) 16 | static let mediaListItemTitle: Font = .system(size: 16, weight: .regular) 17 | static let mediaListItemSubtitle: Font = .system(size: 13, weight: .regular) 18 | static let mediaListItemFooter: Font = .system(size: 15, weight: .regular) 19 | static let tabbar: Font = .system(size: 10, weight: .regular) 20 | } 21 | 22 | extension Font { 23 | static var appFont: AppFont.Type { 24 | AppFont.self 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Consts/Palette.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Palette.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 04.12.2024. 6 | // 7 | 8 | import UIKit 9 | 10 | enum Palette { 11 | enum PlayerCard {} 12 | } 13 | 14 | extension Palette { 15 | static var playerCard: Palette.PlayerCard.Type { 16 | Palette.PlayerCard.self 17 | } 18 | 19 | static let appBackground: UIColor = .dynamic( 20 | light: .white, 21 | dark: .black 22 | ) 23 | 24 | static let brand: UIColor = .dynamic( 25 | light: UIColor.systemPink, 26 | dark: UIColor.systemPink 27 | ) 28 | 29 | static let artworkBorder: UIColor = .dynamic( 30 | light: .black.withAlphaComponent(0.2), 31 | dark: .white.withAlphaComponent(0.2) 32 | ) 33 | 34 | static let artworkBackground: UIColor = .dynamic( 35 | light: UIColor(r: 233, g: 233, b: 234, a: 255), 36 | dark: UIColor(r: 39, g: 39, b: 41, a: 255) 37 | ) 38 | 39 | static let buttonBackground: UIColor = .dynamic( 40 | light: UIColor(r: 238, g: 238, b: 239, a: 255), 41 | dark: UIColor(r: 28, g: 28, b: 31, a: 255) 42 | ) 43 | 44 | static let textSecondary: UIColor = .dynamic( 45 | light: UIColor(r: 138, g: 138, b: 142, a: 255), 46 | dark: UIColor(r: 141, g: 141, b: 147, a: 255) 47 | ) 48 | 49 | static let textTertiary: UIColor = .dynamic( 50 | light: UIColor(r: 127, g: 127, b: 127, a: 255), 51 | dark: UIColor(r: 128, g: 128, b: 128, a: 255) 52 | ) 53 | 54 | static func appBackground(expandProgress: CGFloat) -> UIColor { 55 | UIColor { 56 | $0.userInterfaceStyle == .light 57 | ? .white 58 | : lerp(.black, .palette.stackedDarkBackground, expandProgress) ?? .black 59 | } 60 | } 61 | } 62 | 63 | extension Palette.PlayerCard { 64 | static let opaque: UIColor = .white 65 | static let translucent: UIColor = .init(white: 0.784, alpha: 0.816) 66 | static let artworkBackground: UIColor = .dynamic( 67 | light: Palette.platinum, 68 | dark: Palette.taupeGray 69 | ) 70 | } 71 | 72 | private extension Palette { 73 | static let taupeGray = UIColor(red: 0.525, green: 0.525, blue: 0.545, alpha: 1) 74 | static let platinum = UIColor(red: 0.898, green: 0.898, blue: 0.913, alpha: 1) 75 | static let stackedDarkBackground = UIColor(red: 0.0784, green: 0.0784, blue: 0.086, alpha: 1) 76 | } 77 | 78 | extension UIColor { 79 | static var palette: Palette.Type { 80 | Palette.self 81 | } 82 | } 83 | 84 | @inline(__always) 85 | func lerp(_ v0: V, _ v1: V, _ t: T) -> V { 86 | return v0 + V(t) * (v1 - v0) 87 | } 88 | 89 | func lerp(_ v0: UIColor, _ v1: UIColor, _ t: T) -> UIColor? { 90 | var red0: CGFloat = 0 91 | var green0: CGFloat = 0 92 | var blue0: CGFloat = 0 93 | var alpha0: CGFloat = 0 94 | var red1: CGFloat = 0 95 | var green1: CGFloat = 0 96 | var blue1: CGFloat = 0 97 | var alpha1: CGFloat = 0 98 | 99 | v0.getRed(&red0, green: &green0, blue: &blue0, alpha: &alpha0) 100 | v1.getRed(&red1, green: &green1, blue: &blue1, alpha: &alpha1) 101 | return UIColor( 102 | red: lerp(red0, red1, t), 103 | green: lerp(green0, green1, t), 104 | blue: lerp(blue0, blue1, t), 105 | alpha: lerp(alpha0, alpha1, t) 106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Consts/ViewConst.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewConst.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 04.12.2024. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | import UIKit 11 | 12 | enum ViewConst {} 13 | 14 | @MainActor 15 | extension ViewConst { 16 | static let playerCardPaddings: CGFloat = 32 17 | static let screenPaddings: CGFloat = 20 18 | static let tabbarHeight: CGFloat = safeAreaInsets.bottom + 92 19 | static let compactNowPlayingHeight: CGFloat = 56 20 | static var safeAreaInsets: EdgeInsets { 21 | if let windowScene = UIApplication.shared.connectedScenes 22 | .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene, 23 | let window = windowScene.windows.first(where: { $0.isKeyWindow }) { 24 | return EdgeInsets(window.safeAreaInsets) 25 | } else { 26 | return EdgeInsets(UIEdgeInsets.zero) 27 | } 28 | } 29 | } 30 | 31 | extension EdgeInsets { 32 | init(_ insets: UIEdgeInsets) { 33 | self.init( 34 | top: insets.top, 35 | leading: insets.left, 36 | bottom: insets.bottom, 37 | trailing: insets.right 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/CustomTabBar/CustomTabView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomTabView.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 01.01.2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomTabView: View { 11 | @Binding var selection: TabBarItem 12 | let content: Content 13 | @State private var tabs: [TabBarItem] = [] 14 | 15 | init( 16 | selection: Binding, 17 | @ViewBuilder content: () -> Content 18 | ) { 19 | _selection = selection 20 | self.content = content() 21 | } 22 | 23 | var body: some View { 24 | ZStack(alignment: .bottom) { 25 | content 26 | .frame(maxWidth: .infinity, maxHeight: .infinity) 27 | CustomTabBarView( 28 | tabs: tabs, 29 | selection: $selection, 30 | localSelection: selection 31 | ) 32 | } 33 | .onPreferenceChange(TabBarItemsPreferenceKey.self) { value in 34 | tabs = value 35 | } 36 | } 37 | } 38 | 39 | // MARK: CustomTabBarView 40 | 41 | private struct CustomTabBarView: View { 42 | let tabs: [TabBarItem] 43 | @Binding var selection: TabBarItem 44 | @State var localSelection: TabBarItem 45 | 46 | var body: some View { 47 | tabBar 48 | .onChange(of: selection) { 49 | withAnimation(.easeInOut) { 50 | localSelection = selection 51 | } 52 | } 53 | } 54 | 55 | func tabView(_ tab: TabBarItem) -> some View { 56 | VStack(spacing: 5) { 57 | tab.image 58 | .resizable() 59 | .aspectRatio(contentMode: .fit) 60 | .frame(width: 24, height: 24) 61 | Text(tab.title) 62 | .font(.appFont.tabbar) 63 | .padding(.bottom, 2) 64 | } 65 | .foregroundStyle(localSelection == tab ? Color(.palette.brand) : Color.gray) 66 | .frame(maxWidth: .infinity) 67 | } 68 | 69 | var tabBar: some View { 70 | HStack { 71 | ForEach(tabs, id: \.self) { tab in 72 | tabView(tab) 73 | .onTapGesture { 74 | selection = tab 75 | } 76 | } 77 | } 78 | .padding(.top, 68) 79 | .background( 80 | BlurView(style: .systemChromeMaterial) 81 | .mask(mask) 82 | .ignoresSafeArea(edges: .bottom) 83 | ) 84 | } 85 | 86 | var mask: some View { 87 | VStack(spacing: 0) { 88 | LinearGradient( 89 | gradient: Gradient(colors: [.black.opacity(0.01), .black]), 90 | startPoint: .top, 91 | endPoint: .bottom 92 | ) 93 | .frame(height: 30) 94 | Color.black 95 | } 96 | } 97 | } 98 | 99 | // MARK: Preference and ViewModifier 100 | 101 | struct TabBarItemsPreferenceKey: PreferenceKey { 102 | static let defaultValue: [TabBarItem] = [] 103 | 104 | static func reduce(value: inout [TabBarItem], nextValue: () -> [TabBarItem]) { 105 | value += nextValue() 106 | } 107 | } 108 | 109 | struct TabBarItemViewModifer: ViewModifier { 110 | let tab: TabBarItem 111 | @Binding var selection: TabBarItem 112 | 113 | func body(content: Content) -> some View { 114 | content 115 | .opacity(selection == tab ? 1.0 : 0.0) 116 | .preference(key: TabBarItemsPreferenceKey.self, value: [tab]) 117 | } 118 | } 119 | 120 | struct TabBarItemViewModiferWithOnAppear: ViewModifier { 121 | let tab: TabBarItem 122 | @Binding var selection: TabBarItem 123 | 124 | @ViewBuilder func body(content: Content) -> some View { 125 | if selection == tab { 126 | content 127 | .opacity(1) 128 | .preference(key: TabBarItemsPreferenceKey.self, value: [tab]) 129 | } else { 130 | Text("") 131 | .opacity(0) 132 | .preference(key: TabBarItemsPreferenceKey.self, value: [tab]) 133 | } 134 | } 135 | } 136 | 137 | extension View { 138 | func tabBarItem(tab: TabBarItem, selection: Binding) -> some View { 139 | modifier(TabBarItemViewModiferWithOnAppear(tab: tab, selection: selection)) 140 | } 141 | } 142 | 143 | #Preview { 144 | RootView() 145 | .environment(PlayListController()) 146 | } 147 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/CustomTabBar/TabBarItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabBarItem.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 01.01.2025. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum TabBarItem: Hashable, CaseIterable { 11 | case home, search 12 | } 13 | 14 | extension TabBarItem { 15 | var title: String { 16 | switch self { 17 | case .home: return "Home" 18 | case .search: return "Search" 19 | } 20 | } 21 | 22 | var image: Image { 23 | switch self { 24 | case .home: return Image("img_home") 25 | case .search: return Image(systemName: "magnifyingglass") 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/DominantColors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DominantColors.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 01.12.2024. 6 | // 7 | 8 | import UIKit 9 | 10 | struct Lab { 11 | let l: CGFloat 12 | let a: CGFloat 13 | let b: CGFloat 14 | } 15 | 16 | extension Lab { 17 | func deltaECIE94(rhs: Lab) -> Double { 18 | let lhs = self 19 | 20 | let kL = 1.0 21 | let kC = 1.0 22 | let kH = 1.0 23 | let k1 = 0.045 24 | let k2 = 0.015 25 | let sL = 1.0 26 | 27 | let c1 = sqrt(pow(lhs.a, 2) + pow(lhs.b, 2)) 28 | let sC = 1 + k1 * c1 29 | let sH = 1 + k2 * c1 30 | 31 | let deltaL = lhs.l - rhs.l 32 | let deltaA = lhs.a - rhs.a 33 | let deltaB = lhs.b - rhs.b 34 | 35 | let c2 = sqrt(pow(rhs.a, 2) + pow(rhs.b, 2)) 36 | let deltaCab = c1 - c2 37 | 38 | let deltaHab = sqrt(pow(deltaA, 2) + pow(deltaB, 2) - pow(deltaCab, 2)) 39 | 40 | let p1 = pow(deltaL / (kL * sL), 2) 41 | let p2 = pow(deltaCab / (kC * sC), 2) 42 | let p3 = pow(deltaHab / (kH * sH), 2) 43 | 44 | return sqrt(p1 + p2 + p3) 45 | } 46 | } 47 | 48 | extension Lab { 49 | init(xyz: XYZ) { 50 | let refX = 95.047 51 | let refY = 100.0 52 | let refZ = 108.883 53 | 54 | func transform(value: Double) -> Double { 55 | value > 0.008856 ? pow(value, 1 / 3) : (7.787 * value) + (16 / 116) 56 | } 57 | 58 | let x = transform(value: xyz.x / refX) 59 | let y = transform(value: xyz.y / refY) 60 | let z = transform(value: xyz.z / refZ) 61 | 62 | self.init( 63 | l: (116.0 * y) - 16.0, 64 | a: 500.0 * (x - y), 65 | b: 200.0 * (y - z) 66 | ) 67 | } 68 | } 69 | 70 | struct XYZ: Equatable { 71 | let x: CGFloat 72 | let y: CGFloat 73 | let z: CGFloat 74 | } 75 | 76 | extension XYZ { 77 | init(r: CGFloat, g: CGFloat, b: CGFloat) { 78 | func transform(value: Double) -> Double { 79 | value > 0.04045 ? pow((value + 0.055) / 1.055, 2.4) : value / 12.92 80 | } 81 | 82 | let red = transform(value: r) * 100.0 83 | let green = transform(value: g) * 100.0 84 | let blue = transform(value: b) * 100.0 85 | 86 | self.init( 87 | x: red * 0.4124 + green * 0.3576 + blue * 0.1805, 88 | y: red * 0.2126 + green * 0.7152 + blue * 0.0722, 89 | z: red * 0.0193 + green * 0.1192 + blue * 0.9505 90 | ) 91 | } 92 | } 93 | 94 | struct ColorFrequency: Hashable { 95 | let color: UIColor 96 | let frequency: Double 97 | } 98 | 99 | enum DominantColorQuality { 100 | case low 101 | case fair 102 | case high 103 | case best 104 | } 105 | 106 | extension UIImage { 107 | func dominantColorFrequencies( 108 | with quality: DominantColorQuality = .fair 109 | ) -> [ColorFrequency]? { 110 | let image = cgImage?.colorSpace?.model == .rgb ? self : convertToRGBColorspace() 111 | let maxNumberOfColors = 500 112 | let targetSize = quality.targetSize(for: resolution) 113 | guard let colorCounts = image? 114 | .resize(to: targetSize) 115 | .cgImage? 116 | .colorCounts(maxAlpha: 150)? 117 | .sorted(by: { $0.count > $1.count }) 118 | .prefix(maxNumberOfColors) 119 | else { return nil } 120 | 121 | let similarColors = mergeSimilar(colors: Array(colorCounts), diffThreshold: 6, maxCount: 20) 122 | let dominantColors = mergeSimilar(colors: similarColors, diffThreshold: 18, maxCount: 6) 123 | let totalDominantColors = dominantColors.reduce(into: 0) { $0 += $1.count } 124 | return dominantColors.map { 125 | let percentage = (Double($0.count) / Double(totalDominantColors)) 126 | return ColorFrequency( 127 | color: UIColor( 128 | r: $0.color.rgb.r, 129 | g: $0.color.rgb.g, 130 | b: $0.color.rgb.b, 131 | a: 255 132 | ), 133 | frequency: percentage 134 | ) 135 | } 136 | .filter { $0.frequency > 0.005 } 137 | } 138 | } 139 | 140 | private func mergeSimilar(colors: [ColorCount], diffThreshold: CGFloat = 10.0, maxCount: Int) -> [ColorCount] { 141 | var result = [ColorCount]() 142 | for colorCount in colors { 143 | var bestMatchScore: CGFloat? 144 | var bestMatchColor: ColorCount? 145 | for dominantColor in result { 146 | let differenceScore = 147 | CGFloat(colorCount.color.lab.deltaECIE94(rhs: dominantColor.color.lab)) 148 | .rounded(.toNearestOrEven, precision: 100) 149 | if differenceScore < bestMatchScore ?? CGFloat(Int.max) { 150 | bestMatchScore = differenceScore 151 | bestMatchColor = dominantColor 152 | } 153 | } 154 | if let bestMatchScore = bestMatchScore, bestMatchScore < diffThreshold { 155 | bestMatchColor = bestMatchColor.map { 156 | ColorCount(color: $0.color, count: $0.count + 1) 157 | } 158 | } else { 159 | result.append(colorCount) 160 | } 161 | } 162 | return result 163 | .prefix(maxCount) 164 | .sorted { $0.count > $1.count } 165 | } 166 | 167 | private struct RGBBytes: Hashable { 168 | let r: UInt8 169 | let g: UInt8 170 | let b: UInt8 171 | } 172 | 173 | private struct Color { 174 | let rgb: RGBBytes 175 | let lab: Lab 176 | } 177 | 178 | private struct ColorCount { 179 | let color: Color 180 | let count: Int 181 | } 182 | 183 | extension DominantColorQuality { 184 | var prefferedPixelCount: CGFloat? { 185 | switch self { 186 | case .low: return 1000 187 | case .fair: return 10000 188 | case .high: return 100_000 189 | case .best: return nil 190 | } 191 | } 192 | 193 | func targetSize(for size: CGSize) -> CGSize { 194 | guard let prefferedPixelCount = prefferedPixelCount else { 195 | return size 196 | } 197 | guard size.pixelCount > prefferedPixelCount else { 198 | return size 199 | } 200 | return size.transformToFit(in: prefferedPixelCount) 201 | } 202 | } 203 | 204 | private extension CGImage { 205 | func colorCounts(maxAlpha: UInt8) -> [ColorCount]? { 206 | guard colorSpace?.model == .rgb, 207 | bitsPerPixel == 32 || bitsPerPixel == 24, 208 | let data = dataProvider?.data, 209 | let dataPtr = CFDataGetBytePtr(data), 210 | let componentLayout = bitmapInfo.componentLayout 211 | else { 212 | return nil 213 | } 214 | 215 | let isAlphaPremultiplied = bitmapInfo.chromaIsPremultipliedByAlpha 216 | let bytesPerPixel = bitsPerPixel / 8 217 | let numPixels = CFDataGetLength(data) / bytesPerPixel 218 | 219 | return (0 ..< numPixels) 220 | .compactMap { pixelIndex in 221 | RGBBytes( 222 | data: dataPtr + pixelIndex * bytesPerPixel, 223 | layout: componentLayout, 224 | isAlphaPremultiplied: isAlphaPremultiplied, 225 | maxAlpha: maxAlpha 226 | ) 227 | } 228 | .frequencies 229 | .map { 230 | ColorCount( 231 | color: Color(rgb: $0.key), 232 | count: $0.value 233 | ) 234 | } 235 | } 236 | } 237 | 238 | extension Sequence where Element: Hashable { 239 | var frequencies: [Element: Int] { 240 | let frequencyPairs = map { ($0, 1) } 241 | return Dictionary(frequencyPairs, uniquingKeysWith: +) 242 | } 243 | } 244 | 245 | extension Color { 246 | init(rgb: RGBBytes) { 247 | self.rgb = rgb 248 | lab = Lab( 249 | xyz: XYZ( 250 | r: (CGFloat(rgb.r) / 255).rounded(.toNearestOrEven, precision: 100), 251 | g: (CGFloat(rgb.g) / 255).rounded(.toNearestOrEven, precision: 100), 252 | b: (CGFloat(rgb.b) / 255).rounded(.toNearestOrEven, precision: 100) 253 | ).rounded(.toNearestOrEven, precision: 100) 254 | ).rounded(.toNearestOrEven, precision: 100) 255 | } 256 | } 257 | 258 | extension Lab { 259 | func rounded(_ rule: FloatingPointRoundingRule, precision: Int) -> Lab { 260 | Lab( 261 | l: l.rounded(rule, precision: precision), 262 | a: a.rounded(rule, precision: precision), 263 | b: b.rounded(rule, precision: precision) 264 | ) 265 | } 266 | } 267 | 268 | extension XYZ { 269 | func rounded(_ rule: FloatingPointRoundingRule, precision: Int) -> XYZ { 270 | XYZ( 271 | x: x.rounded(rule, precision: precision), 272 | y: y.rounded(rule, precision: precision), 273 | z: z.rounded(rule, precision: precision) 274 | ) 275 | } 276 | } 277 | 278 | private extension CGFloat { 279 | func rounded(_ rule: FloatingPointRoundingRule, precision: Int) -> CGFloat { 280 | return (self * CGFloat(precision)).rounded(rule) / CGFloat(precision) 281 | } 282 | } 283 | 284 | private extension CGSize { 285 | var pixelCount: CGFloat { 286 | return width * height 287 | } 288 | 289 | /// Returns a new size of the target area, keeping the same aspect ratio. 290 | func transformToFit(in targetPixelCount: CGFloat) -> CGSize { 291 | let ratio = pixelCount / targetPixelCount 292 | let targetSize = CGSize(width: width / sqrt(ratio), height: height / sqrt(ratio)) 293 | 294 | return targetSize 295 | } 296 | } 297 | 298 | extension RGBBytes { 299 | init?( 300 | data: UnsafePointer, 301 | layout: CGBitmapInfo.ComponentLayout, 302 | isAlphaPremultiplied: Bool, 303 | maxAlpha: UInt8 304 | ) { 305 | switch layout.count { 306 | case 3: 307 | let c0 = data[0] 308 | let c1 = data[1] 309 | let c2 = data[2] 310 | if layout == .bgr { 311 | self.init(r: c2, g: c1, b: c0) 312 | } else { 313 | self.init(r: c0, g: c1, b: c2) 314 | } 315 | case 4: 316 | let c0 = data[0] 317 | let c1 = data[1] 318 | let c2 = data[2] 319 | let c3 = data[3] 320 | let r: UInt8 321 | let g: UInt8 322 | let b: UInt8 323 | let a: UInt8 324 | switch layout { 325 | case .abgr: 326 | a = c0; b = c1; g = c2; r = c3 327 | case .argb: 328 | a = c0; r = c1; g = c2; b = c3 329 | case .bgra: 330 | b = c0; g = c1; r = c2; a = c3 331 | case .rgba: 332 | r = c0; g = c1; b = c2; a = c3 333 | default: 334 | return nil 335 | } 336 | if a < maxAlpha { 337 | return nil 338 | } 339 | if isAlphaPremultiplied, a > 0 { 340 | self.init(r: r / a, g: g / a, b: b / a) 341 | } 342 | self.init(r: r, g: g, b: b) 343 | default: 344 | self.init(r: 0, g: 0, b: 0) 345 | } 346 | } 347 | } 348 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Extensions/UIColor+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIColor+Extensions.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 01.12.2024. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIColor { 11 | convenience init(r: UInt8, g: UInt8, b: UInt8, a: UInt8) { 12 | self.init( 13 | red: CGFloat(r) / 255, 14 | green: CGFloat(g) / 255, 15 | blue: CGFloat(b) / 255, 16 | alpha: CGFloat(a) / 255 17 | ) 18 | } 19 | 20 | static func dynamic(light: UIColor, dark: UIColor) -> UIColor { 21 | UIColor { $0.userInterfaceStyle == .dark ? dark : light } 22 | } 23 | 24 | var hex: String { 25 | let cgColorInRGB = cgColor.converted( 26 | to: CGColorSpace(name: CGColorSpace.sRGB)!, 27 | intent: .defaultIntent, options: nil 28 | )! 29 | let colorRef = cgColorInRGB.components 30 | let r = colorRef?[0] ?? 0 31 | let g = colorRef?[1] ?? 0 32 | let b = ((colorRef?.count ?? 0) > 2 ? colorRef?[2] : g) ?? 0 33 | let a = cgColor.alpha 34 | 35 | var color = String( 36 | format: "#%02lX%02lX%02lX", 37 | lroundf(Float(r * 255)), 38 | lroundf(Float(g * 255)), 39 | lroundf(Float(b * 255)) 40 | ) 41 | 42 | if a < 1 { 43 | color += String(format: "%02lX", lroundf(Float(a * 255))) 44 | } 45 | 46 | return color 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Extensions/UIEdgeInsets+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIEdgeInsets+Extensions.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 01.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension UIEdgeInsets { 11 | var edgeInsets: EdgeInsets { 12 | EdgeInsets(top: top, leading: left, bottom: bottom, trailing: right) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Extensions/UIImage+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+Extensions.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 01.12.2024. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIImage { 11 | func convertToRGBColorspace() -> UIImage? { 12 | let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) 13 | let context = CGContext( 14 | data: nil, 15 | width: Int(size.width), 16 | height: Int(size.height), 17 | bitsPerComponent: 8, 18 | bytesPerRow: 0, 19 | space: CGColorSpaceCreateDeviceRGB(), 20 | bitmapInfo: UInt32(bitmapInfo.rawValue) 21 | ) 22 | guard let context = context, let cgImage = cgImage else { return nil } 23 | context.draw(cgImage, in: CGRect(origin: CGPoint.zero, size: size)) 24 | guard let convertedImage = context.makeImage() else { return nil } 25 | return UIImage(cgImage: convertedImage) 26 | } 27 | 28 | var resolution: CGSize { 29 | return CGSize(width: size.width * scale, height: size.height * scale) 30 | } 31 | 32 | func resize(to targetSize: CGSize) -> UIImage { 33 | guard targetSize != resolution else { 34 | return self 35 | } 36 | 37 | let format = UIGraphicsImageRendererFormat() 38 | format.scale = 1 39 | format.opaque = true 40 | let renderer = UIGraphicsImageRenderer(size: targetSize, format: format) 41 | let resizedImage = renderer.image { _ in 42 | self.draw(in: CGRect(origin: CGPoint.zero, size: targetSize)) 43 | } 44 | return resizedImage 45 | } 46 | } 47 | 48 | extension CGBitmapInfo { 49 | enum ComponentLayout { 50 | case bgra 51 | case abgr 52 | case argb 53 | case rgba 54 | case bgr 55 | case rgb 56 | 57 | var count: Int { 58 | switch self { 59 | case .bgr, .rgb: return 3 60 | default: return 4 61 | } 62 | } 63 | } 64 | 65 | var componentLayout: ComponentLayout? { 66 | guard let alphaInfo = CGImageAlphaInfo(rawValue: rawValue & Self.alphaInfoMask.rawValue) else { return nil } 67 | let isLittleEndian = contains(.byteOrder32Little) 68 | 69 | if alphaInfo == .none { 70 | return isLittleEndian ? .bgr : .rgb 71 | } 72 | let alphaIsFirst = alphaInfo == .premultipliedFirst || alphaInfo == .first || alphaInfo == .noneSkipFirst 73 | 74 | if isLittleEndian { 75 | return alphaIsFirst ? .bgra : .abgr 76 | } else { 77 | return alphaIsFirst ? .argb : .rgba 78 | } 79 | } 80 | 81 | var chromaIsPremultipliedByAlpha: Bool { 82 | let alphaInfo = CGImageAlphaInfo(rawValue: rawValue & Self.alphaInfoMask.rawValue) 83 | return alphaInfo == .premultipliedFirst || alphaInfo == .premultipliedLast 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Extensions/UIScreen+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIScreen+Extensions.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 20.11.2024. 6 | // 7 | 8 | import UIKit 9 | 10 | extension UIScreen { 11 | static var deviceCornerRadius: CGFloat { 12 | main.value(forKey: "_displayCornerRadius") as? CGFloat ?? 0 13 | } 14 | 15 | static var hairlineWidth: CGFloat { 16 | 1 / main.scale 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Modifiers/Hidden.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Hidden.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 08.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | func hidden(_ shouldHide: Bool) -> some View { 12 | opacity(shouldHide ? 0 : 1) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Modifiers/MeasureSizeModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MeasureSizeModifier.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 03.12.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SizePreferenceKey: PreferenceKey { 11 | nonisolated static var defaultValue: CGSize { .zero } 12 | 13 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) { 14 | value = nextValue() 15 | } 16 | } 17 | 18 | struct MeasureSizeModifier: ViewModifier { 19 | func body(content: Content) -> some View { 20 | content 21 | .overlay { 22 | GeometryReader { geometry in 23 | Color.clear 24 | .preference(key: SizePreferenceKey.self, value: geometry.size) 25 | } 26 | } 27 | } 28 | } 29 | 30 | extension View { 31 | func measureSize(perform action: @escaping (CGSize) -> Void) -> some View { 32 | modifier(MeasureSizeModifier()) 33 | .onPreferenceChange(SizePreferenceKey.self, perform: action) 34 | } 35 | } 36 | 37 | private struct Measurements: View { 38 | let showSize: Bool 39 | let color: Color 40 | @State private var size: CGSize = .zero 41 | 42 | var body: some View { 43 | label.measureSize { size = $0 } 44 | } 45 | 46 | var label: some View { 47 | ZStack(alignment: .topTrailing) { 48 | Rectangle() 49 | .strokeBorder(color, lineWidth: 1) 50 | 51 | Text("H:\(size.height.formatted) W:\(size.width.formatted)") 52 | .foregroundColor(.black) 53 | .font(.system(size: 8)) 54 | .opacity(showSize ? 1 : 0) 55 | } 56 | } 57 | } 58 | 59 | extension View { 60 | func measured(_ showSize: Bool = true, _ color: Color = Color.red) -> some View { 61 | overlay(Measurements(showSize: showSize, color: color)) 62 | } 63 | } 64 | 65 | private extension CGFloat { 66 | var formatted: String { 67 | abs(remainder(dividingBy: 1)) <= 0.001 68 | ? .init(format: "%.0f", self) 69 | : .init(format: "%.2f", self) 70 | } 71 | } 72 | 73 | private extension Double { 74 | var formatted: String { 75 | CGFloat(self).formatted 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/Modifiers/PressGesture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PressGesture.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 18.12.2024. 6 | // 7 | 8 | import Combine 9 | import SwiftUI 10 | 11 | struct PressGesture: ViewModifier { 12 | @GestureState private var startTimestamp: Date? 13 | @State private var timePublisher: Publishers.Autoconnect 14 | private var onPressed: () -> Void 15 | private var onPressing: (TimeInterval) -> Void 16 | private var onEnded: () -> Void 17 | 18 | init( 19 | interval: TimeInterval = 0.1, 20 | onPressed: @escaping () -> Void, 21 | onPressing: @escaping (TimeInterval) -> Void, 22 | onEnded: @escaping () -> Void 23 | ) { 24 | self.onPressed = onPressed 25 | self.onPressing = onPressing 26 | self.onEnded = onEnded 27 | _timePublisher = State( 28 | wrappedValue: Timer.publish( 29 | every: interval, 30 | tolerance: nil, 31 | on: .current, 32 | in: .common 33 | ).autoconnect() 34 | ) 35 | } 36 | 37 | func body(content: Content) -> some View { 38 | content 39 | .gesture( 40 | DragGesture(minimumDistance: 0, coordinateSpace: .local) 41 | .updating($startTimestamp) { _, current, _ in 42 | if current == nil { 43 | onPressed() 44 | current = Date() 45 | } 46 | } 47 | .onEnded { _ in 48 | onEnded() 49 | } 50 | ) 51 | .onReceive(timePublisher) { timer in 52 | if let startTimestamp = startTimestamp { 53 | onPressing(timer.timeIntervalSince(startTimestamp)) 54 | } 55 | } 56 | } 57 | } 58 | 59 | extension View { 60 | func onPressGesture( 61 | interval: TimeInterval = 0.1, 62 | onPressed: @escaping () -> Void, 63 | onPressing: @escaping (TimeInterval) -> Void, 64 | onEnded: @escaping () -> Void 65 | ) -> some View { 66 | modifier( 67 | PressGesture( 68 | interval: interval, 69 | onPressed: onPressed, 70 | onPressing: onPressing, 71 | onEnded: onEnded 72 | ) 73 | ) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/PanGesture.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PanGesture.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 17.11.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PanGesture: UIGestureRecognizerRepresentable { 11 | var onChange: (Value) -> Void 12 | var onEnd: (Value) -> Void 13 | 14 | func makeUIGestureRecognizer(context _: Context) -> UIPanGestureRecognizer { 15 | let gesture = UIPanGestureRecognizer() 16 | return gesture 17 | } 18 | 19 | func updateUIGestureRecognizer(_: UIPanGestureRecognizer, context _: Context) {} 20 | 21 | func handleUIGestureRecognizerAction(_ recognizer: UIPanGestureRecognizer, context _: Context) { 22 | let state = recognizer.state 23 | let translation = recognizer.translation(in: recognizer.view).toSize() 24 | let velocity = recognizer.velocity(in: recognizer.view).toSize() 25 | let value = Value(translation: translation, velocity: velocity) 26 | 27 | if state == .began || state == .changed { 28 | onChange(value) 29 | } else { 30 | onEnd(value) 31 | } 32 | } 33 | 34 | struct Value { 35 | var translation: CGSize 36 | var velocity: CGSize 37 | } 38 | } 39 | 40 | extension CGPoint { 41 | func toSize() -> CGSize { 42 | CGSize(width: x, height: y) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /AppleMusicStylePlayer/UI/UniversalOverlay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UniversalOverlay.swift 3 | // AppleMusicStylePlayer 4 | // 5 | // Created by Alexey Vorobyov on 17.11.2024. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | @ViewBuilder 12 | func universalOverlay( 13 | animation: Animation? = .snappy, 14 | show: Binding, 15 | @ViewBuilder content: @escaping () -> Content 16 | ) -> some View { 17 | modifier( 18 | UniversalOverlayModifier( 19 | animation: animation, 20 | show: show, 21 | viewContent: content 22 | ) 23 | ) 24 | } 25 | } 26 | 27 | /// Root View Wrapper 28 | /// In order to place views on top of the SwiftUl app, we need to create 29 | /// an overlay window on top of the active key window. This RootView wrapper 30 | /// will create an overlay window, which allows us to place our views on top of the current key window. 31 | /// To make this work, you will have to wrap your app's entry view with this wrapper. 32 | struct OverlayableRootView: View { 33 | var content: Content 34 | fileprivate var properties = UniversalOverlayProperties() 35 | 36 | init(@ViewBuilder content: @escaping () -> Content) { 37 | self.content = content() 38 | } 39 | 40 | var body: some View { 41 | content 42 | .environment(properties) 43 | .onAppear { 44 | let windowScene = (UIApplication.shared.connectedScenes.first as? UIWindowScene) 45 | if let windowScene, properties.window == nil { 46 | let window = PassthroughWindow(windowScene: windowScene) 47 | window.isHidden = false 48 | window.isUserInteractionEnabled = true 49 | let rootViewController = UIHostingController( 50 | rootView: UniversalOverlayViews().environment(properties) 51 | ) 52 | rootViewController.view.backgroundColor = .clear 53 | window.rootViewController = rootViewController 54 | properties.window = window 55 | } 56 | } 57 | } 58 | } 59 | 60 | @Observable 61 | private class UniversalOverlayProperties { 62 | var window: UIWindow? 63 | var views: [OverlayView] = [] 64 | 65 | struct OverlayView: Identifiable { 66 | var id: String = UUID().uuidString 67 | var view: AnyView 68 | } 69 | } 70 | 71 | private struct UniversalOverlayModifier: ViewModifier { 72 | var animation: Animation? 73 | @Binding var show: Bool 74 | @ViewBuilder var viewContent: ViewContent 75 | @Environment(UniversalOverlayProperties.self) private var properties 76 | @State private var viewID: String? 77 | 78 | func body(content: Content) -> some View { 79 | content 80 | .onChange(of: show, initial: true) { _, newValue in 81 | if newValue { 82 | addView() 83 | } else { 84 | removeView() 85 | } 86 | } 87 | } 88 | 89 | private func addView() { 90 | if properties.window != nil && viewID == nil { 91 | viewID = UUID().uuidString 92 | guard let viewID else { return } 93 | 94 | withAnimation(animation) { 95 | properties.views.append( 96 | .init(id: viewID, view: .init(viewContent)) 97 | ) 98 | } 99 | } 100 | } 101 | 102 | private func removeView() { 103 | if let viewID { 104 | withAnimation(animation) { 105 | properties.views.removeAll(where: { $0.id == viewID }) 106 | } 107 | 108 | self.viewID = nil 109 | } 110 | } 111 | } 112 | 113 | private struct UniversalOverlayViews: View { 114 | @Environment(UniversalOverlayProperties.self) private var properties 115 | var body: some View { 116 | ZStack { 117 | ForEach(properties.views) { $0.view } 118 | } 119 | } 120 | } 121 | 122 | /// Since overlay window been added on top of the current active window, 123 | /// the interactions of the main key window are disabled. This can be solved 124 | /// by making the overlay window as a pass through window, which will only 125 | /// be interactable if the overlay window has some views on it. Otherwise, 126 | /// the interactions will be passed to the main window. 127 | private class PassthroughWindow: UIWindow { 128 | /// Previously, before iOS 18, this code was enough to make sure to create 129 | /// a passthrough view/window, but from iOs 18, this code won't have interactions 130 | /// on the passthrough window views. Let me show you a demo of it, and then let's see how we can fix it. 131 | override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { 132 | guard let hitView = super.hitTest(point, with: event), let rootView = rootViewController?.view else { 133 | return nil 134 | } 135 | 136 | if #available(iOS 18, *) { 137 | for subview in rootView.subviews.reversed() { 138 | /// Finding if any of rootview's subview is receiving hit test. 139 | let pointInSubView = subview.convert(point, from: rootView) 140 | if subview.hitTest(pointInSubView, with: event) != nil { 141 | return hitView 142 | } 143 | } 144 | return nil 145 | } else { 146 | return hitView == rootView ? nil : hitView 147 | } 148 | } 149 | } 150 | 151 | #Preview { 152 | OverlayableRootView { 153 | OverlaidRootView() 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Alexey Vorobyov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apple Music style now playing view in SwiftUI 2 | 3 | Apple Music now playing card animations and transition recreated using SwiftUI 4 | 5 | https://github.com/user-attachments/assets/772f8a9e-40c9-4d80-a8d5-2eb63a5e396d 6 | 7 | --------------------------------------------------------------------------------