diff --git a/AirSync.xcodeproj/project.pbxproj b/AirSync.xcodeproj/project.pbxproj index fecd3ae7..1c4b02e0 100644 --- a/AirSync.xcodeproj/project.pbxproj +++ b/AirSync.xcodeproj/project.pbxproj @@ -16,18 +16,18 @@ B99B04C62F641E1400FF3E2D /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = B99B04C52F641E1400FF3E2D /* AppIcon.icon */; }; B9AEBC0A2E6235D3006BA027 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = B9AEBC092E6235D3006BA027 /* Sparkle */; }; B9B1C00D2E94E15D0005F6CB /* LottieUI in Frameworks */ = {isa = PBXBuildFile; productRef = B9B1C00C2E94E15D0005F6CB /* LottieUI */; }; - B9C0AA122F7D2B4F00BA0961 /* scrcpy-server-v3.3.4 in Resources */ = {isa = PBXBuildFile; fileRef = B9C0AA112F7D2B4F00BA0961 /* scrcpy-server-v3.3.4 */; }; B9C3181E2E37AA1D00367F16 /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = B9C3181D2E37AA1D00367F16 /* QRCode */; }; B9D2631B2F60D97900628704 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = B9D2631A2F60D97900628704 /* Sentry */; }; B9D263292F60D9CF00628704 /* SentrySwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = B9D263282F60D9CF00628704 /* SentrySwiftUI */; }; B9D742FC2E39CF850053128A /* Swifter in Frameworks */ = {isa = PBXBuildFile; productRef = B9D742FB2E39CF850053128A /* Swifter */; }; + B9E423832FBD914900526D40 /* scrcpy-server in Resources */ = {isa = PBXBuildFile; fileRef = B9E423822FBD914900526D40 /* scrcpy-server */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ B9673B572E35A2A1006D284A /* AirSync.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AirSync.app; sourceTree = BUILT_PRODUCTS_DIR; }; B995A3322E4D2B3F00FA7A41 /* AppIcon-uni.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = "AppIcon-uni.icon"; sourceTree = ""; }; B99B04C52F641E1400FF3E2D /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; - B9C0AA112F7D2B4F00BA0961 /* scrcpy-server-v3.3.4 */ = {isa = PBXFileReference; lastKnownFileType = file; path = "scrcpy-server-v3.3.4"; sourceTree = ""; }; + B9E423822FBD914900526D40 /* scrcpy-server */ = {isa = PBXFileReference; lastKnownFileType = file; path = "scrcpy-server"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -80,12 +80,12 @@ B9673B4E2E35A2A1006D284A = { isa = PBXGroup; children = ( - B9C0AA112F7D2B4F00BA0961 /* scrcpy-server-v3.3.4 */, B995A3322E4D2B3F00FA7A41 /* AppIcon-uni.icon */, B9673B592E35A2A1006D284A /* airsync-mac */, B9D263192F60D97900628704 /* Frameworks */, B9673B582E35A2A1006D284A /* Products */, B99B04C52F641E1400FF3E2D /* AppIcon.icon */, + B9E423822FBD914900526D40 /* scrcpy-server */, ); sourceTree = ""; }; @@ -190,8 +190,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + B9E423832FBD914900526D40 /* scrcpy-server in Resources */, B99B04C62F641E1400FF3E2D /* AppIcon.icon in Resources */, - B9C0AA122F7D2B4F00BA0961 /* scrcpy-server-v3.3.4 in Resources */, B995A3332E4D2B3F00FA7A41 /* AppIcon-uni.icon in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -283,7 +283,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = NO; @@ -295,13 +295,14 @@ INFOPLIST_KEY_CFBundleDisplayName = AirSync; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_LSUIElement = NO; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "AirSync uses Bluetooth to sync notifications and media controls with your Android phone when Wi-Fi is unavailable."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 3.2.0; + MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -459,7 +460,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = NO; @@ -471,13 +472,14 @@ INFOPLIST_KEY_CFBundleDisplayName = AirSync; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_LSUIElement = NO; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "AirSync uses Bluetooth to sync notifications and media controls with your Android phone when Wi-Fi is unavailable."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 3.2.0; + MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -507,7 +509,7 @@ CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 28; + CURRENT_PROJECT_VERSION = 29; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = WCB4HTANA6; ENABLE_APP_SANDBOX = NO; @@ -519,13 +521,14 @@ INFOPLIST_KEY_CFBundleDisplayName = AirSync; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_LSUIElement = NO; + INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "AirSync uses Bluetooth to sync notifications and media controls with your Android phone when Wi-Fi is unavailable."; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.5; - MARKETING_VERSION = 3.2.0; + MARKETING_VERSION = 4.0.0; PRODUCT_BUNDLE_IDENTIFIER = "sameerasw.airsync-mac"; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/1024.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/1024.png deleted file mode 100644 index cfb23854..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/1024.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/128.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/128.png deleted file mode 100644 index e21699b9..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/128.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/16.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/16.png deleted file mode 100644 index bffe17f1..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/16.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/256 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/256 1.png deleted file mode 100644 index c4902a42..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/256 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/256.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/256.png deleted file mode 100644 index c4902a42..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/256.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/32 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/32 1.png deleted file mode 100644 index 75fea3de..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/32 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/32.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/32.png deleted file mode 100644 index 75fea3de..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/32.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/512 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/512 1.png deleted file mode 100644 index cf478203..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/512 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/512.png deleted file mode 100644 index cf478203..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/64.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/64.png deleted file mode 100644 index 8da3fc75..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/64.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/Contents.json deleted file mode 100644 index e3612256..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p6.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "filename" : "16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "32 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "64.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "256 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "512 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "1024.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/1024.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/1024.png deleted file mode 100644 index d630be34..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/1024.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/128.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/128.png deleted file mode 100644 index 695822ca..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/128.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/16.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/16.png deleted file mode 100644 index 23e64a1c..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/16.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/256 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/256 1.png deleted file mode 100644 index e7a7813a..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/256 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/256.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/256.png deleted file mode 100644 index e7a7813a..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/256.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/32 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/32 1.png deleted file mode 100644 index 38a97317..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/32 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/32.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/32.png deleted file mode 100644 index 38a97317..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/32.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/512 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/512 1.png deleted file mode 100644 index f4b1601c..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/512 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/512.png deleted file mode 100644 index f4b1601c..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/64.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/64.png deleted file mode 100644 index bd0e6ce5..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/64.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/Contents.json deleted file mode 100644 index e3612256..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p7-8.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "filename" : "16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "32 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "64.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "256 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "512 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "1024.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/1024.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/1024.png deleted file mode 100644 index 11ecee76..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/1024.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/128.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/128.png deleted file mode 100644 index c20fe3aa..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/128.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/16.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/16.png deleted file mode 100644 index c603b3de..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/16.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/256 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/256 1.png deleted file mode 100644 index 3430808a..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/256 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/256.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/256.png deleted file mode 100644 index 3430808a..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/256.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/32 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/32 1.png deleted file mode 100644 index 94d67efa..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/32 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/32.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/32.png deleted file mode 100644 index 94d67efa..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/32.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/512 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/512 1.png deleted file mode 100644 index 93684f75..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/512 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/512.png deleted file mode 100644 index 93684f75..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/64.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/64.png deleted file mode 100644 index e8980bd8..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/64.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/Contents.json deleted file mode 100644 index e3612256..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-p9-10.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "filename" : "16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "32 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "64.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "256 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "512 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "1024.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/1024.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/1024.png deleted file mode 100644 index 926c5304..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/1024.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/128.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/128.png deleted file mode 100644 index 7d81e81d..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/128.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/16.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/16.png deleted file mode 100644 index a7724d30..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/16.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/256 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/256 1.png deleted file mode 100644 index 9408a096..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/256 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/256.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/256.png deleted file mode 100644 index 9408a096..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/256.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/32 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/32 1.png deleted file mode 100644 index 6cf6d48e..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/32 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/32.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/32.png deleted file mode 100644 index 6cf6d48e..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/32.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/512 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/512 1.png deleted file mode 100644 index ac588ee8..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/512 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/512.png deleted file mode 100644 index ac588ee8..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/64.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/64.png deleted file mode 100644 index b7c736ea..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/64.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/Contents.json deleted file mode 100644 index e3612256..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-pfold.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "filename" : "16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "32 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "64.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "256 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "512 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "1024.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/1024.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/1024.png deleted file mode 100644 index 01da4c56..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/1024.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/128.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/128.png deleted file mode 100644 index 3fd0ceb6..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/128.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/16.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/16.png deleted file mode 100644 index 1dfd11a1..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/16.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/256 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/256 1.png deleted file mode 100644 index 42c9689e..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/256 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/256.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/256.png deleted file mode 100644 index 42c9689e..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/256.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/32 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/32 1.png deleted file mode 100644 index a461cc1a..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/32 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/32.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/32.png deleted file mode 100644 index a461cc1a..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/32.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/512 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/512 1.png deleted file mode 100644 index f461fbc2..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/512 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/512.png deleted file mode 100644 index f461fbc2..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/64.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/64.png deleted file mode 100644 index 1e8c7480..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/64.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/Contents.json deleted file mode 100644 index e3612256..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s21.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "filename" : "16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "32 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "64.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "256 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "512 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "1024.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/1024.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/1024.png deleted file mode 100644 index aeaabf73..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/1024.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/128.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/128.png deleted file mode 100644 index 1a6320a1..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/128.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/16.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/16.png deleted file mode 100644 index d1ba8fa0..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/16.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/256 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/256 1.png deleted file mode 100644 index 5fbd8a07..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/256 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/256.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/256.png deleted file mode 100644 index 5fbd8a07..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/256.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/32 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/32 1.png deleted file mode 100644 index 78c1728c..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/32 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/32.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/32.png deleted file mode 100644 index 78c1728c..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/32.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/512 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/512 1.png deleted file mode 100644 index 5ac76d7f..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/512 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/512.png deleted file mode 100644 index 5ac76d7f..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/64.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/64.png deleted file mode 100644 index 22526c54..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/64.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/Contents.json deleted file mode 100644 index e3612256..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-s2x.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "filename" : "16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "32 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "64.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "256 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "512 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "1024.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/1024.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/1024.png deleted file mode 100644 index f2e1d203..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/1024.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/128.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/128.png deleted file mode 100644 index aecd8b73..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/128.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/16.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/16.png deleted file mode 100644 index bcbf2c41..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/16.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/256 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/256 1.png deleted file mode 100644 index 2d0ecb84..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/256 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/256.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/256.png deleted file mode 100644 index 2d0ecb84..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/256.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/32 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/32 1.png deleted file mode 100644 index d50863bb..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/32 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/32.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/32.png deleted file mode 100644 index d50863bb..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/32.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/512 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/512 1.png deleted file mode 100644 index b0adbb65..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/512 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/512.png deleted file mode 100644 index b0adbb65..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/64.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/64.png deleted file mode 100644 index 670f6bbc..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/64.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/Contents.json deleted file mode 100644 index e3612256..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zflip.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "filename" : "16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "32 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "64.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "256 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "512 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "1024.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/1024.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/1024.png deleted file mode 100644 index 20cdfa83..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/1024.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/128.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/128.png deleted file mode 100644 index 1fa1b007..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/128.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/16.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/16.png deleted file mode 100644 index 9b2bae10..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/16.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/256 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/256 1.png deleted file mode 100644 index dfa8c664..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/256 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/256.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/256.png deleted file mode 100644 index dfa8c664..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/256.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/32 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/32 1.png deleted file mode 100644 index 8f96c396..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/32 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/32.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/32.png deleted file mode 100644 index 8f96c396..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/32.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/512 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/512 1.png deleted file mode 100644 index bd8efc17..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/512 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/512.png deleted file mode 100644 index bd8efc17..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/64.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/64.png deleted file mode 100644 index 502a59c6..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/64.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/Contents.json deleted file mode 100644 index e3612256..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIcon-zfold.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "filename" : "16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "32 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "64.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "256 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "512 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "1024.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/1024.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/1024.png deleted file mode 100644 index fd51692f..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/1024.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/128.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/128.png deleted file mode 100644 index 8cd036d6..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/128.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/16.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/16.png deleted file mode 100644 index d7e46a66..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/16.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/256 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/256 1.png deleted file mode 100644 index 26f73525..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/256 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/256.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/256.png deleted file mode 100644 index 26f73525..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/256.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/32 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/32 1.png deleted file mode 100644 index c5d16b53..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/32 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/32.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/32.png deleted file mode 100644 index c5d16b53..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/32.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/512 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/512 1.png deleted file mode 100644 index cddda2f9..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/512 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/512.png deleted file mode 100644 index cddda2f9..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/64.png b/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/64.png deleted file mode 100644 index b721595c..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/64.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/Contents.json deleted file mode 100644 index e3612256..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "filename" : "16.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "16x16" - }, - { - "filename" : "32 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "16x16" - }, - { - "filename" : "32.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "32x32" - }, - { - "filename" : "64.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "32x32" - }, - { - "filename" : "128.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "128x128" - }, - { - "filename" : "256 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "128x128" - }, - { - "filename" : "256.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "256x256" - }, - { - "filename" : "512 1.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "256x256" - }, - { - "filename" : "512.png", - "idiom" : "mac", - "scale" : "1x", - "size" : "512x512" - }, - { - "filename" : "1024.png", - "idiom" : "mac", - "scale" : "2x", - "size" : "512x512" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p6.imageset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p6.imageset/512.png deleted file mode 100644 index cf478203..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p6.imageset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p6.imageset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p6.imageset/Contents.json deleted file mode 100644 index b1fccae6..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p6.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "512.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p7-8.imageset/512 1.png b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p7-8.imageset/512 1.png deleted file mode 100644 index f4b1601c..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p7-8.imageset/512 1.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p7-8.imageset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p7-8.imageset/Contents.json deleted file mode 100644 index 2a54f9a3..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p7-8.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "512 1.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p9-10.imageset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p9-10.imageset/512.png deleted file mode 100644 index 93684f75..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p9-10.imageset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p9-10.imageset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p9-10.imageset/Contents.json deleted file mode 100644 index b1fccae6..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-p9-10.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "512.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-pfold.imageset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-pfold.imageset/512.png deleted file mode 100644 index ac588ee8..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-pfold.imageset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-pfold.imageset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-pfold.imageset/Contents.json deleted file mode 100644 index b1fccae6..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-pfold.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "512.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-s21.imageset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-s21.imageset/512.png deleted file mode 100644 index f461fbc2..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-s21.imageset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-s21.imageset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-s21.imageset/Contents.json deleted file mode 100644 index b1fccae6..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-s21.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "512.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-s2x.imageset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-s2x.imageset/512.png deleted file mode 100644 index 5ac76d7f..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-s2x.imageset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-s2x.imageset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-s2x.imageset/Contents.json deleted file mode 100644 index b1fccae6..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-s2x.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "512.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-zflip.imageset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-zflip.imageset/512.png deleted file mode 100644 index b0adbb65..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-zflip.imageset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-zflip.imageset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-zflip.imageset/Contents.json deleted file mode 100644 index b1fccae6..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-zflip.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "512.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-zfold.imageset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-zfold.imageset/512.png deleted file mode 100644 index bd8efc17..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-zfold.imageset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-zfold.imageset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-zfold.imageset/Contents.json deleted file mode 100644 index b1fccae6..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage-zfold.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "512.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage.imageset/256.png b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage.imageset/256.png deleted file mode 100644 index 26f73525..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage.imageset/256.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage.imageset/512.png b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage.imageset/512.png deleted file mode 100644 index cddda2f9..00000000 Binary files a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage.imageset/512.png and /dev/null differ diff --git a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage.imageset/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/AppIconImage.imageset/Contents.json deleted file mode 100644 index e21dd4eb..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/AppIconImage.imageset/Contents.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "images" : [ - { - "filename" : "256.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "512.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/AppIcons/Contents.json b/airsync-mac/Assets.xcassets/AppIcons/Contents.json deleted file mode 100644 index 73c00596..00000000 --- a/airsync-mac/Assets.xcassets/AppIcons/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/airsync-mac/Assets.xcassets/logo.bluetooth.symbolset/Contents.json b/airsync-mac/Assets.xcassets/logo.bluetooth.symbolset/Contents.json new file mode 100644 index 00000000..e62bc233 --- /dev/null +++ b/airsync-mac/Assets.xcassets/logo.bluetooth.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "symbols" : [ + { + "filename" : "logo.bluetooth.svg", + "idiom" : "universal" + } + ] +} diff --git a/airsync-mac/Assets.xcassets/logo.bluetooth.symbolset/logo.bluetooth.svg b/airsync-mac/Assets.xcassets/logo.bluetooth.symbolset/logo.bluetooth.svg new file mode 100644 index 00000000..158b52fe --- /dev/null +++ b/airsync-mac/Assets.xcassets/logo.bluetooth.symbolset/logo.bluetooth.svg @@ -0,0 +1,165 @@ + + + + + + + + + + Weight/Scale Variations + Ultralight + Thin + Light + Regular + Medium + Semibold + Bold + Heavy + Black + + + + + + + + + + + Design Variations + Symbols are supported in up to nine weights and three scales. + For optimal layout with text and other symbols, vertically align + symbols with the adjacent text. + + + + + + Margins + Leading and trailing margins on the left and right side of each symbol + can be adjusted by modifying the x-location of the margin guidelines. + Modifications are automatically applied proportionally to all + scales and weights. + + + + Exporting + Symbols should be outlined when exporting to ensure the + design is preserved when submitting to Xcode. + Template v.3.0 + Requires Xcode 13 or greater + Generated from logo.bluetooth + Typeset at 100 points + Small + Medium + Large + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/airsync-mac/Components/Buttons/GlassButtonView.swift b/airsync-mac/Components/Buttons/GlassButtonView.swift index b8d5f25d..fc74c7f7 100644 --- a/airsync-mac/Components/Buttons/GlassButtonView.swift +++ b/airsync-mac/Components/Buttons/GlassButtonView.swift @@ -31,8 +31,18 @@ struct GlassButtonView: View { ProgressView() .controlSize(.small) .frame(minWidth: 20) - } else if customIconSizingActive, iconOnly, let (imgView, altText) = iconImageView() { - imgView.accessibilityLabel(Text(altText)) + } else if iconOnly { + if customIconSizingActive, let (imgView, altText) = iconImageView() { + imgView.accessibilityLabel(Text(altText)) + } else { + if let systemImage { + Image(systemName: systemImage) + } else if let image { + Image(image) + } else { + Text(label) + } + } } else { if let systemImage { Label(label, systemImage: systemImage) } else if let image { Label(label, image: image) } diff --git a/airsync-mac/Components/WebView/HelpWebSheet.swift b/airsync-mac/Components/WebView/HelpWebSheet.swift index 86e75fd4..e238b287 100644 --- a/airsync-mac/Components/WebView/HelpWebSheet.swift +++ b/airsync-mac/Components/WebView/HelpWebSheet.swift @@ -10,8 +10,8 @@ import SwiftUI struct HelpWebSheet: View { @Binding var isPresented: Bool - @State private var webURL: URL = URL(string: "https://airsync.notion.site")! - @State private var currentURL: URL = URL(string: "https://airsync.notion.site")! + @State private var webURL: URL = URL(string: "https://sameerasw.com/docs/airsync")! + @State private var currentURL: URL = URL(string: "https://sameerasw.com/docs/airsync")! var body: some View { ZStack { diff --git a/airsync-mac/Components/WebView/WebView.swift b/airsync-mac/Components/WebView/WebView.swift index 87cd0b44..ccfa79ce 100644 --- a/airsync-mac/Components/WebView/WebView.swift +++ b/airsync-mac/Components/WebView/WebView.swift @@ -49,26 +49,11 @@ struct WebView: NSViewRepresentable { let js = """ var style = document.createElement('style'); style.innerHTML = ` - - /* notion-transparency */ - body { - background-color: #00000000 !important; - background: #00000000 !important; - transition: - background-color 0.5s ease-in-out, - background 0.5s ease-in-out, - border 0.5s ease-in-out, - box-shadow 0.5s ease-in-out !important; - } - .notion-app-inner, - .notion-cursor-listener, - .notion-frame, - .notion-sidebar-container, - header { - background-color: #00000000 !important; - background: #00000000 !important; - box-shadow: none !important; + body, html, .docs-wrapper, + #background { + background-color: transparent !important; + background: none !important; transition: background-color 0.5s ease-in-out, background 0.5s ease-in-out, @@ -76,28 +61,6 @@ struct WebView: NSViewRepresentable { box-shadow 0.5s ease-in-out !important; } - /* notion-hide elements */ - div.autolayout-row.autolayout-fill-width.autolayout-center.autolayout-space { - display: none !important; - } - - /* notion-rounded banner */ - .layout-full img { - border-radius: 1em !important; - } - - /* custom */ - header{ - position: absolute !important; - top: 0 !important; - left: 0 !important; - width: 100% !important; - } - - header{ - display: none; - } - `; document.head.appendChild(style); """ diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 6500e4ec..03c65d68 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -5,6 +5,7 @@ // Created by Sameera Sandakelum on 2025-07-29. // import SwiftUI +import ServiceManagement import Foundation import Cocoa import Combine @@ -22,12 +23,14 @@ class AppState: ObservableObject { private var clipboardCancellable: AnyCancellable? private var lastClipboardValue: String? = nil private var shouldSkipSave = false + private var cancellables = Set() private static let licenseDetailsKey = "licenseDetails" @Published var isOS26: Bool = true init() { - self.isPlus = false + let isPlusLoaded = UserDefaults.standard.bool(forKey: "isPlus") + self.isPlus = isPlusLoaded let adbPortValue = UserDefaults.standard.integer(forKey: "adbPort") self.adbPort = adbPortValue == 0 ? 5555 : UInt16(adbPortValue) @@ -46,7 +49,23 @@ class AppState: ObservableObject { let savedMaxLength = UserDefaults.standard.integer(forKey: "menubarTextMaxLength") self.menubarTextMaxLength = savedMaxLength > 0 ? savedMaxLength : 30 + self.showMenubarIcon = UserDefaults.standard.object(forKey: "showMenubarIcon") == nil ? true : UserDefaults.standard.bool(forKey: "showMenubarIcon") + self.menubarBatteryStyle = UserDefaults.standard.string(forKey: "menubarBatteryStyle") ?? "both" + self.showMenubarMusicIcon = UserDefaults.standard.object(forKey: "showMenubarMusicIcon") == nil ? true : UserDefaults.standard.bool(forKey: "showMenubarMusicIcon") + self.showMenubarAlbumArt = UserDefaults.standard.object(forKey: "showMenubarAlbumArt") == nil ? true : UserDefaults.standard.bool(forKey: "showMenubarAlbumArt") + if UserDefaults.standard.object(forKey: "showMenubarCallDetails") == nil { + self.showMenubarCallDetails = isPlusLoaded + } else { + self.showMenubarCallDetails = UserDefaults.standard.bool(forKey: "showMenubarCallDetails") && (!licenseCheck || isPlusLoaded) + } + self.menubarFontSize = UserDefaults.standard.object(forKey: "menubarFontSize") == nil ? 12.0 : UserDefaults.standard.double(forKey: "menubarFontSize") + self.menubarUnreadBadgeStyle = UserDefaults.standard.string(forKey: "menubarUnreadBadgeStyle") ?? "badge" + self.menubarUnreadBadgeColor = UserDefaults.standard.string(forKey: "menubarUnreadBadgeColor") ?? "accent" + self.showMenubarPillStroke = UserDefaults.standard.bool(forKey: "showMenubarPillStroke") + self.menubarNotificationStyle = UserDefaults.standard.string(forKey: "menubarNotificationStyle") ?? "both" + self.isClipboardSyncEnabled = UserDefaults.standard.bool(forKey: "isClipboardSyncEnabled") + self.autoStartAtLogin = UserDefaults.standard.bool(forKey: "autoStartAtLogin") self.windowOpacity = UserDefaults.standard.double(forKey: "windowOpacity") self.hideDockIcon = UserDefaults.standard.bool(forKey: "hideDockIcon") self.alwaysOpenWindow = UserDefaults.standard.bool(forKey: "alwaysOpenWindow") @@ -56,6 +75,11 @@ class AppState: ObservableObject { self.autoAcceptQuickShare = UserDefaults.standard.bool(forKey: "autoAcceptQuickShare") self.quickShareEnabled = UserDefaults.standard.object(forKey: "quickShareEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "quickShareEnabled") + self.isFileAccessEnabled = UserDefaults.standard.object(forKey: "isFileAccessEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "isFileAccessEnabled") + self.popupSharedImages = UserDefaults.standard.object(forKey: "popupSharedImages") == nil ? true : UserDefaults.standard.bool(forKey: "popupSharedImages") + let limit = UserDefaults.standard.integer(forKey: "sharedImagePopupsLimit") + self.sharedImagePopupsLimit = limit == 0 ? 3 : limit + self.popupSharedImagesOnLeft = UserDefaults.standard.bool(forKey: "popupSharedImagesOnLeft") let savedNotificationMode = UserDefaults.standard.string(forKey: "callNotificationMode") ?? CallNotificationMode.popup.rawValue self.callNotificationMode = CallNotificationMode(rawValue: savedNotificationMode) ?? .popup @@ -73,6 +97,7 @@ class AppState: ObservableObject { self.scrcpyResolution = res self.useADBWhenPossible = UserDefaults.standard.object(forKey: "useADBWhenPossible") == nil ? true : UserDefaults.standard.bool(forKey: "useADBWhenPossible") + self.useNativeMirroringByDefault = UserDefaults.standard.bool(forKey: "useNativeMirroringByDefault") self.isMusicCardHidden = UserDefaults.standard.bool(forKey: "isMusicCardHidden") self.isCrashReportingEnabled = UserDefaults.standard.object(forKey: "isCrashReportingEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "isCrashReportingEnabled") @@ -97,6 +122,20 @@ class AppState: ObservableObject { self.licenseDetails = AppState.loadLicenseDetailsFromUserDefaults() + self.isBLEEnabled = UserDefaults.standard.bool(forKey: "isBLEEnabled") + self.isBLEAutoConnectEnabled = UserDefaults.standard.object(forKey: "isBLEAutoConnectEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "isBLEAutoConnectEnabled") + + if isBLEEnabled { + BLECentralManager.shared.startScanning() + } + + BLECentralManager.shared.$connectionStatus + .receive(on: RunLoop.main) + .sink { [weak self] status in + self?.handleBLEStatusChange(status) + } + .store(in: &cancellables) + if isClipboardSyncEnabled { startClipboardMonitoring() } @@ -107,18 +146,31 @@ class AppState: ObservableObject { UserDefaults.standard.lastLicenseSuccessfulCheckDate = Date().addingTimeInterval(-(24 * 60 * 60)) #else Task { + // Delay startup check by 5 minutes (300 seconds) to ensure network connection is fully established + try? await Task.sleep(nanoseconds: 300_000_000_000) await Gumroad().checkLicenseIfNeeded() } #endif + if !self.isPlus && licenseCheck { + self.showMenubarAlbumArt = false + self.menubarNotificationStyle = "count" + } + loadAppsFromDisk() loadPinnedApps() // Ensure dock icon visibility is applied on launch updateDockIconVisibility() + updateAutoStart() // Reset mirroring state on launch to prevent auto-opening if it was open during last session self.isNativeMirroring = false + + startMediaTimer() + + // Cleanup stale WebDAV mounts from previous sessions + WebDAVManager.shared.unmount() } @Published var minAndroidVersion = Bundle.main.infoDictionary?["AndroidVersion"] as? String ?? "2.0.0" @@ -131,8 +183,14 @@ class AppState: ObservableObject { // Validate pinned apps when connecting to a device validatePinnedApps() loadRecentApps() + + // Mount WebDAV volume + if newDevice.ipAddress != "BLE" && isPlus && isFileAccessEnabled { + WebDAVManager.shared.mount(ipAddress: newDevice.ipAddress, port: 9081, volumeName: newDevice.name) + } } else { recentApps = [] + WebDAVManager.shared.unmount() } // Automatically switch to the appropriate tab when device connection state changes @@ -141,12 +199,66 @@ class AppState: ObservableObject { } else if oldValue == nil { self.selectedTab = .notifications } + + // BLE scan management: pause when a regular (non-BLE) connection is active + let isRegularConnection = device?.ipAddress != nil && device?.ipAddress != "BLE" + let wasRegularConnection = oldValue?.ipAddress != nil && oldValue?.ipAddress != "BLE" + + if isRegularConnection && !wasRegularConnection { + // Regular connection established — stop BLE scanning to save power/bandwidth + if isBLEEnabled && BLECentralManager.shared.connectionStatus == .scanning { + print("[state] Regular connection active — pausing BLE scan") + BLECentralManager.shared.stopScanning() + } + } else if !isRegularConnection && wasRegularConnection { + // Regular connection lost — resume BLE scanning if BLE is enabled and not already BLE-connected + if isBLEEnabled && !BLECentralManager.shared.isAuthenticated { + print("[state] Regular connection lost — resuming BLE scan") + BLECentralManager.shared.isManuallyDisconnected = false + BLECentralManager.shared.startScanning() + } + } } } @Published var notifications: [Notification] = [] @Published var activeMacIp: String? = nil @Published var callEvents: [CallEvent] = [] - @Published var activeCall: CallEvent? = nil + private var callDurationTimer: AnyCancellable? + @Published var activeCallDurationSec: Int = 0 + + @Published var activeCall: CallEvent? = nil { + didSet { + if activeCall != nil { + startCallTimer() + } else { + stopCallTimer() + } + } + } + + private func startCallTimer() { + callDurationTimer?.cancel() + + if let call = activeCall { + activeCallDurationSec = max(0, Int(Date().timeIntervalSince1970 - Double(call.timestamp) / 1000.0)) + } + + callDurationTimer = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + guard let self = self, let call = self.activeCall else { + self?.stopCallTimer() + return + } + self.activeCallDurationSec = max(0, Int(Date().timeIntervalSince1970 - Double(call.timestamp) / 1000.0)) + } + } + + private func stopCallTimer() { + callDurationTimer?.cancel() + callDurationTimer = nil + activeCallDurationSec = 0 + } @Published var status: DeviceStatus? = nil @Published var myDevice: Device? = nil @Published var port: UInt16 = Defaults.serverPort @@ -172,6 +284,7 @@ class AppState: ObservableObject { @Published var shouldRefreshQR: Bool = false @Published var webSocketStatus: WebSocketStatus = .stopped @Published var selectedTab: TabIdentifier = .qr + @Published var selectedSettingsTab: SettingsTab = .myMac @Published var adbConnected: Bool = false { didSet { @@ -188,9 +301,17 @@ class AppState: ObservableObject { @Published var recentApps: [AndroidApp] = [] @Published var isNativeMirroring: Bool = false + @Published var temporaryDragLabel: String? = nil + + // MARK: - Centralized Media Seekbar State + @Published var mediaPosition: Double = 0 + var isDraggingMedia: Bool = false + var lastMediaSeekTime: Date = .distantPast + var seekTargetPosition: Double = -1 + private var mediaTickTimer: AnyCancellable? var isConnectedOverLocalNetwork: Bool { - guard let ip = device?.ipAddress else { return true } + guard let ip = device?.ipAddress, ip != "BLE" else { return false } // Tailscale IPs usually start with 100. return !ip.hasPrefix("100.") } @@ -221,6 +342,79 @@ class AppState: ObservableObject { } } + @Published var showMenubarIcon: Bool { + didSet { + UserDefaults.standard.set(showMenubarIcon, forKey: "showMenubarIcon") + } + } + + @Published var menubarBatteryStyle: String { + didSet { + UserDefaults.standard.set(menubarBatteryStyle, forKey: "menubarBatteryStyle") + } + } + + @Published var showMenubarMusicIcon: Bool { + didSet { + UserDefaults.standard.set(showMenubarMusicIcon, forKey: "showMenubarMusicIcon") + } + } + + @Published var showMenubarAlbumArt: Bool { + didSet { + UserDefaults.standard.set(showMenubarAlbumArt, forKey: "showMenubarAlbumArt") + } + } + + @Published var menubarFontSize: Double { + didSet { + UserDefaults.standard.set(menubarFontSize, forKey: "menubarFontSize") + } + } + + @Published var menubarUnreadBadgeStyle: String { + didSet { + UserDefaults.standard.set(menubarUnreadBadgeStyle, forKey: "menubarUnreadBadgeStyle") + } + } + + @Published var menubarUnreadBadgeColor: String { + didSet { + UserDefaults.standard.set(menubarUnreadBadgeColor, forKey: "menubarUnreadBadgeColor") + } + } + + @Published var showMenubarPillStroke: Bool { + didSet { + UserDefaults.standard.set(showMenubarPillStroke, forKey: "showMenubarPillStroke") + } + } + + @Published var menubarNotificationStyle: String { + didSet { + UserDefaults.standard.set(menubarNotificationStyle, forKey: "menubarNotificationStyle") + } + } + + @Published var showMenubarCallDetails: Bool { + didSet { + UserDefaults.standard.set(showMenubarCallDetails, forKey: "showMenubarCallDetails") + } + } + + var recentNotifyingPackages: [String] { + var packages: [String] = [] + for notif in notifications { + if !packages.contains(notif.package) { + packages.append(notif.package) + if packages.count == 3 { + break + } + } + } + return packages + } + @Published var scrcpyBitrate: Int = 4 { didSet { UserDefaults.standard.set(scrcpyBitrate, forKey: "scrcpyBitrate") @@ -295,6 +489,35 @@ class AppState: ObservableObject { } } + @Published var autoStartAtLogin: Bool { + didSet { + UserDefaults.standard.set(autoStartAtLogin, forKey: "autoStartAtLogin") + updateAutoStart() + } + } + + @Published var isBLEEnabled: Bool { + didSet { + UserDefaults.standard.set(isBLEEnabled, forKey: "isBLEEnabled") + if isBLEEnabled { + BLECentralManager.shared.isManuallyDisconnected = false + BLECentralManager.shared.startScanning() + } else { + BLECentralManager.shared.stopScanning() + BLECentralManager.shared.disconnect() + } + } + } + + @Published var isBLEAutoConnectEnabled: Bool { + didSet { + UserDefaults.standard.set(isBLEAutoConnectEnabled, forKey: "isBLEAutoConnectEnabled") + if isBLEAutoConnectEnabled { + BLECentralManager.shared.isManuallyDisconnected = false + } + } + } + @Published var alwaysOpenWindow: Bool { didSet { UserDefaults.standard.set(alwaysOpenWindow, forKey: "alwaysOpenWindow") @@ -353,6 +576,24 @@ class AppState: ObservableObject { } } + @Published var popupSharedImages: Bool { + didSet { + UserDefaults.standard.set(popupSharedImages, forKey: "popupSharedImages") + } + } + + @Published var sharedImagePopupsLimit: Int { + didSet { + UserDefaults.standard.set(sharedImagePopupsLimit, forKey: "sharedImagePopupsLimit") + } + } + + @Published var popupSharedImagesOnLeft: Bool { + didSet { + UserDefaults.standard.set(popupSharedImagesOnLeft, forKey: "popupSharedImagesOnLeft") + } + } + @Published var sendNowPlayingStatus: Bool { didSet { UserDefaults.standard.set(sendNowPlayingStatus, forKey: "sendNowPlayingStatus") @@ -372,6 +613,33 @@ class AppState: ObservableObject { } } + @Published var useNativeMirroringByDefault: Bool { + didSet { + UserDefaults.standard.set(useNativeMirroringByDefault, forKey: "useNativeMirroringByDefault") + } + } + + @Published var isFileAccessEnabled: Bool { + didSet { + if !isPlus && licenseCheck { + if isFileAccessEnabled { + isFileAccessEnabled = false + } + UserDefaults.standard.set(false, forKey: "isFileAccessEnabled") + WebDAVManager.shared.unmount() + } else { + UserDefaults.standard.set(isFileAccessEnabled, forKey: "isFileAccessEnabled") + if isFileAccessEnabled { + if let newDevice = device, newDevice.ipAddress != "BLE" { + WebDAVManager.shared.mount(ipAddress: newDevice.ipAddress, port: 9081, volumeName: newDevice.name) + } + } else { + WebDAVManager.shared.unmount() + } + } + } + } + @Published var isCrashReportingEnabled: Bool { didSet { UserDefaults.standard.set(isCrashReportingEnabled, forKey: "isCrashReportingEnabled") @@ -416,6 +684,14 @@ class AppState: ObservableObject { if !shouldSkipSave { UserDefaults.standard.set(isPlus, forKey: "isPlus") } + if !isPlus && licenseCheck { + if isFileAccessEnabled { + isFileAccessEnabled = false + } + showMenubarAlbumArt = false + menubarNotificationStyle = "count" + showMenubarCallDetails = false + } // Notify about license status change for icon revert logic NotificationCenter.default.post(name: NSNotification.Name("LicenseStatusChanged"), object: nil) } @@ -536,6 +812,9 @@ class AppState: ObservableObject { if (callEvent.direction == .incoming && callEvent.state == .ringing) || (callEvent.direction == .outgoing && callEvent.state == .offhook) { + // Always set activeCall so the Menubar card is visible during a call in any mode + self.activeCall = callEvent + // Handle notification based on user preference if callNotificationMode == .notification { // Only show system notification @@ -548,7 +827,6 @@ class AppState: ObservableObject { if callEvent.direction == .incoming && callEvent.state == .ringing && self.ringForCalls { self.playCallRingtone() } - self.activeCall = callEvent print("[state] Active call set for popup display") } else if callNotificationMode == .none { // Don't show anything @@ -691,10 +969,16 @@ class AppState: ObservableObject { self.status = nil self.currentDeviceWallpaperBase64 = nil + // Disconnect BLE + BLECentralManager.shared.disconnect() + // Clean up Quick Share state if QuickShareManager.shared.transferState != .idle { QuickShareManager.shared.transferState = .idle } + + // Clear all shared image popups on disconnect + SharedImagePopupManager.shared.dismissAll() if self.adbConnected { ADBConnector.disconnectADB() @@ -952,6 +1236,9 @@ class AppState: ObservableObject { } """ WebSocketServer.shared.sendClipboardUpdate(message) + + // Also send via BLE + BLETransportBridge.shared.sendClipboard(text) } func updateClipboardFromAndroid(_ text: String) { @@ -1183,6 +1470,65 @@ class AppState: ObservableObject { } } + func updateAutoStart() { + let service = SMAppService.mainApp + if autoStartAtLogin { + if service.status != .enabled { + do { + try service.register() + print("[state] Successfully registered auto start at login") + } catch { + print("[state] Failed to register auto start at login: \(error.localizedDescription)") + } + } + } else { + if service.status == .enabled { + do { + try service.unregister() + print("[state] Successfully unregistered auto start at login") + } catch { + print("[state] Failed to unregister auto start at login: \(error.localizedDescription)") + } + } + } + } + + private func handleBLEStatusChange(_ status: BLECentralManager.BLEConnectionStatus) { + if status == .authenticated { + if self.device == nil { + updateVirtualDeviceForBLE() + } + } else if status == .disconnected { + // Only clear device if it's the virtual BLE device + if self.device?.ipAddress == "BLE" { + self.device = nil + self.status = nil + self.notifications = [] + } + // Resume scanning after BLE disconnect (unless a regular connection is already active) + let hasRegularConnection = self.device?.ipAddress != nil && self.device?.ipAddress != "BLE" + if isBLEEnabled && !hasRegularConnection && !BLECentralManager.shared.isManuallyDisconnected { + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + if self.isBLEEnabled && self.device?.ipAddress != "BLE" && !BLECentralManager.shared.isAuthenticated { + BLECentralManager.shared.startScanning() + } + } + } + } + } + + private func updateVirtualDeviceForBLE() { + let name = BLECentralManager.shared.connectedDeviceName ?? "Android Device" + self.device = Device( + name: name, + ipAddress: "BLE", + port: 0, + version: "2.0.0", + adbPorts: [] + ) + print("[state] (BLE) Created virtual device: \(name)") + } + /// Revalidates the current network adapter selection and falls back to auto if no longer valid func revalidateNetworkAdapter() { let currentSelection = selectedNetworkAdapterName @@ -1222,4 +1568,69 @@ class AppState: ObservableObject { print("[state] Using saved network adapter: \(savedName) -> \(validIP)") return savedName } + + // MARK: - Media Seekbar Sync Logic + + func startMediaTimer() { + guard mediaTickTimer == nil else { return } + mediaTickTimer = Timer.publish(every: 1.0, on: .main, in: .common) + .autoconnect() + .sink { [weak self] _ in + guard let self = self, + let music = self.status?.music, + music.isPlaying, + !music.isBuffering, + !self.isDraggingMedia else { return } + + let next = self.mediaPosition + 1.0 + self.mediaPosition = music.duration > 0 ? min(next, music.duration) : next + } + } + + func stopMediaTimer() { + mediaTickTimer?.cancel() + mediaTickTimer = nil + } + + func syncMediaPosition(incoming: Double) { + guard incoming >= 0 else { return } + + let sinceSeeked = Date().timeIntervalSince(lastMediaSeekTime) + + if seekTargetPosition >= 0 && sinceSeeked < 10.0 { + if abs(incoming - seekTargetPosition) <= 10.0 { + seekTargetPosition = -1 + applyMediaPosition(incoming) + } + return + } + + if seekTargetPosition >= 0 { seekTargetPosition = -1 } + + if sinceSeeked < 8.0 && incoming < mediaPosition - 5.0 { return } + + applyMediaPosition(incoming) + } + + private func applyMediaPosition(_ incoming: Double) { + let delta = incoming - mediaPosition + if delta > 3.0 || delta < -10.0 { + mediaPosition = incoming + } + } + + func handleMediaSeek(to position: Double) { + seekTargetPosition = position + lastMediaSeekTime = Date() + mediaPosition = position + WebSocketServer.shared.seekTo(positionSeconds: position) + } + + func handleTrackChange() { + seekTargetPosition = -1 + lastMediaSeekTime = .distantPast + if let pos = status?.music?.position { + syncMediaPosition(incoming: pos) + } + } } diff --git a/airsync-mac/Core/BLE/BLECentralManager.swift b/airsync-mac/Core/BLE/BLECentralManager.swift new file mode 100644 index 00000000..e7068910 --- /dev/null +++ b/airsync-mac/Core/BLE/BLECentralManager.swift @@ -0,0 +1,409 @@ +import Foundation +import CoreBluetooth +import Combine + +class BLECentralManager: NSObject, ObservableObject { + static let shared = BLECentralManager() + + private var centralManager: CBCentralManager! + private var discoveredPeripheral: CBPeripheral? + + private var characteristics: [CBUUID: CBCharacteristic] = [:] + private var chunkBuffers: [CBUUID: [Int: Data]] = [:] + private var discoveredServiceCount = 0 + private let expectedServiceCount = 4 + + @Published var connectionStatus: BLEConnectionStatus = .disconnected + @Published var connectedDeviceName: String? = nil + struct BLEDiscoveryRecord { + let peripheral: CBPeripheral + var lastSeen: Date + } + + @Published var discoveredPeripherals: [String: BLEDiscoveryRecord] = [:] + @Published var connectingDeviceUUID: String? = nil + + var isManuallyDisconnected = false + + var isConnected: Bool { + connectionStatus != .disconnected && connectionStatus != .scanning + } + + var isAuthenticated: Bool { + connectionStatus == .authenticated + } + + enum BLEConnectionStatus: Equatable { + case disconnected + case scanning + case connected + case authenticated + } + + override init() { + super.init() + centralManager = CBCentralManager(delegate: self, queue: nil) + } + + private var scanTimer: Timer? + private var connectionTimer: Timer? + private var watchdogTimer: Timer? + + func startScanning() { + guard centralManager.state == .poweredOn else { return } + print("[BLE] Starting scan...") + connectionStatus = .scanning + + centralManager.stopScan() + scanTimer?.invalidate() + scanTimer = nil + + centralManager.scanForPeripherals(withServices: [BLEConstants.serviceSystem], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + + // Restart scan periodically to avoid stale states + scanTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + + // Prune stale devices older than 25 seconds + let now = Date() + let staleUUIDs = self.discoveredPeripherals.filter { now.timeIntervalSince($1.lastSeen) > 15.0 }.map { $0.key } + for uuid in staleUUIDs { + self.discoveredPeripherals.removeValue(forKey: uuid) + } + + self.centralManager.stopScan() + self.centralManager.scanForPeripherals(withServices: [BLEConstants.serviceSystem], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + } + } + + func stopScanning() { + scanTimer?.invalidate() + scanTimer = nil + centralManager.stopScan() + if connectionStatus == .scanning { + connectionStatus = .disconnected + } + } + + func disconnect() { + watchdogTimer?.invalidate() + watchdogTimer = nil + isManuallyDisconnected = true + + if let peripheral = discoveredPeripheral { + centralManager.cancelPeripheralConnection(peripheral) + } + connectionStatus = .disconnected + connectingDeviceUUID = nil + discoveredPeripherals.removeAll() + + // Resume scanning to immediately show nearby devices in the unpaired list + if AppState.shared.isBLEEnabled { + startScanning() + } + } + + func write(characteristicUUID: CBUUID, data: Data) { + resetWatchdog() + guard let peripheral = discoveredPeripheral, let char = characteristics[characteristicUUID] else { return } + peripheral.writeValue(data, for: char, type: .withoutResponse) + } + + func writeChunked(characteristicUUID: CBUUID, payload: String) { + let mtu = discoveredPeripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 20 + let chunks = BLEChunkUtil.splitIntoChunks(payload: payload, mtu: mtu) + for chunk in chunks { + write(characteristicUUID: characteristicUUID, data: chunk) + } + } + + var discoveredBLEDevices: [DiscoveredDevice] { + let token = UserDefaults.standard.string(forKey: "bleAuthToken") ?? "" + if token.isEmpty { + return [] + } + + return discoveredPeripherals.values.map { record in + DiscoveredDevice( + deviceId: record.peripheral.identifier.uuidString, + name: record.peripheral.name ?? "Android Device", + ips: ["Bluetooth LE"], + port: 0, + type: "ble", + lastSeen: record.lastSeen + ) + } + } + + func connectManually(toUuid uuidStr: String) { + let token = UserDefaults.standard.string(forKey: "bleAuthToken") ?? "" + if token.isEmpty { + print("[BLE] Cannot connect manually: Devices have never been paired via QR/Wi-Fi before.") + return + } + + guard let record = discoveredPeripherals[uuidStr] else { return } + let peripheral = record.peripheral + print("[BLE] Manual connection requested for \(peripheral.name ?? "Unknown")") + + isManuallyDisconnected = false + discoveredPeripheral = peripheral + centralManager.stopScan() + scanTimer?.invalidate() + scanTimer = nil + + connectingDeviceUUID = uuidStr + connectionStatus = .scanning + centralManager.connect(peripheral, options: [ + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true + ]) + + connectionTimer?.invalidate() + connectionTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { [weak self] _ in + guard let self = self else { return } + print("[BLE] Manual connection timed out, cancelling...") + if let p = self.discoveredPeripheral { + self.centralManager.cancelPeripheralConnection(p) + } + self.discoveredPeripheral = nil + self.connectingDeviceUUID = nil + self.connectionStatus = .disconnected + self.characteristics.removeAll() + self.discoveredServiceCount = 0 + } + } + + private func resetWatchdog() { + DispatchQueue.main.async { + self.watchdogTimer?.invalidate() + self.watchdogTimer = Timer.scheduledTimer(withTimeInterval: 25.0, repeats: false) { [weak self] _ in + print("[BLE] Heartbeat timeout (25s), disconnecting...") + self?.disconnect() + } + } + } +} + +extension BLECentralManager: CBCentralManagerDelegate { + func centralManagerDidUpdateState(_ central: CBCentralManager) { + if central.state == .poweredOn { + if AppState.shared.isBLEEnabled { + print("[BLE] Bluetooth powered on, starting scan") + startScanning() + } + } else { + print("[BLE] Bluetooth state changed: \(central.state.rawValue)") + connectionStatus = .disconnected + } + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { + let name = peripheral.name ?? advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "Unknown" + let serviceUUIDs = advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID] ?? [] + let uuidStr = peripheral.identifier.uuidString + let isNewDevice = discoveredPeripherals[uuidStr] == nil + if isNewDevice { + print("[BLE] Discovered \(name) with RSSI: \(RSSI), Services: \(serviceUUIDs.map { $0.uuidString }.joined(separator: ", "))") + } + + DispatchQueue.main.async { + self.discoveredPeripherals[uuidStr] = BLEDiscoveryRecord(peripheral: peripheral, lastSeen: Date()) + } + + // Auto connect if enabled and not manually disconnected + if AppState.shared.isBLEAutoConnectEnabled && !isManuallyDisconnected { + let token = UserDefaults.standard.string(forKey: "bleAuthToken") ?? "" + if token.isEmpty { + return + } + + discoveredPeripheral = peripheral + centralManager.stopScan() + scanTimer?.invalidate() + scanTimer = nil + + print("[BLE] Attempting auto-connect to \(name)...") + centralManager.connect(peripheral, options: [ + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true + ]) + + // CoreBluetooth connect() has no timeout — it can hang forever with stale pairing data. + connectionTimer?.invalidate() + connectionTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: false) { [weak self] _ in + guard let self = self else { return } + print("[BLE] Connection timed out, cancelling and retrying...") + if let p = self.discoveredPeripheral { + self.centralManager.cancelPeripheralConnection(p) + } + self.discoveredPeripheral = nil + self.connectionStatus = .disconnected + self.characteristics.removeAll() + self.discoveredServiceCount = 0 + + if AppState.shared.isBLEAutoConnectEnabled && !self.isManuallyDisconnected { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + self.startScanning() + } + } + } + } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + connectionTimer?.invalidate() + connectionTimer = nil + connectingDeviceUUID = nil + let name = peripheral.name ?? "Unknown Device" + let maxWrite = peripheral.maximumWriteValueLength(for: .withoutResponse) + print("[BLE] Connected to \(name), Max Write Length: \(maxWrite)") + connectionStatus = .connected + peripheral.delegate = self + peripheral.discoverServices([BLEConstants.serviceSystem, BLEConstants.serviceNotifications, BLEConstants.serviceMedia, BLEConstants.serviceClipboard]) + + resetWatchdog() + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + connectionTimer?.invalidate() + connectionTimer = nil + connectingDeviceUUID = nil + print("[BLE] Failed to connect: \(error?.localizedDescription ?? "Unknown error")") + connectionStatus = .disconnected + discoveredPeripheral = nil + characteristics.removeAll() + discoveredServiceCount = 0 + + // Retry scanning after a delay + if AppState.shared.isBLEAutoConnectEnabled && !isManuallyDisconnected { + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + self.startScanning() + } + } + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + connectionTimer?.invalidate() + connectionTimer = nil + watchdogTimer?.invalidate() + watchdogTimer = nil + + print("[BLE] Disconnected: \(error?.localizedDescription ?? "clean")") + connectionStatus = .disconnected + connectingDeviceUUID = nil + discoveredPeripheral = nil + connectedDeviceName = nil + characteristics.removeAll() + chunkBuffers.removeAll() + discoveredServiceCount = 0 + + if AppState.shared.isBLEAutoConnectEnabled && !isManuallyDisconnected { + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + self.startScanning() + } + } + } +} + +extension BLECentralManager: CBPeripheralDelegate { + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard let services = peripheral.services else { return } + for service in services { + peripheral.discoverCharacteristics(nil, for: service) + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + guard let chars = service.characteristics else { return } + print("[BLE] Discovered \(chars.count) characteristics for service \(service.uuid)") + for char in chars { + characteristics[char.uuid] = char + + if char.properties.contains(.notify) { + print("[BLE] Subscribing to \(char.uuid)") + peripheral.setNotifyValue(true, for: char) + } + } + + discoveredServiceCount += 1 + print("[BLE] Services discovered: \(discoveredServiceCount)/\(expectedServiceCount)") + + // Only attempt auth after ALL services are discovered + if discoveredServiceCount >= expectedServiceCount { + if characteristics[BLEConstants.charAuthToken] != nil { + print("[BLE] All services discovered, attempting authentication...") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.attemptAuthentication() + } + } + } + } + + private func attemptAuthentication() { + guard connectionStatus == .connected else { return } + let token = UserDefaults.standard.string(forKey: "bleAuthToken") ?? "" + if !token.isEmpty, let data = token.data(using: .utf8) { + print("[BLE] Attempting authentication...") + write(characteristicUUID: BLEConstants.charAuthToken, data: data) + } else { + print("[BLE] Auth token is empty, skipping auth and disconnecting because they have never paired") + disconnect() + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + resetWatchdog() + guard let data = characteristic.value else { return } + + switch characteristic.uuid { + case BLEConstants.charAuthResult: + if data.first == BLEConstants.authSuccess { + print("[BLE] Auth Success!") + connectionStatus = .authenticated + connectedDeviceName = discoveredPeripheral?.name ?? "Android Device" + + // Immediately notify Android of Mac status + WebSocketServer.shared.sendMacStatusOverBLE() + + // Also trigger a full fetch (which includes media info) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + MacInfoSyncManager.shared.fetch() + } + } else { + print("[BLE] Auth Failed!") + connectionStatus = .connected // Revert to connected but not auth + } + case BLEConstants.charBatteryLevel: + let level = Int(data.first ?? 0) + print("[BLE] Received Android Battery: \(level)%") + DispatchQueue.main.async { + if AppState.shared.status == nil { + AppState.shared.status = DeviceStatus(battery: DeviceStatus.Battery(level: level, isCharging: false), isPaired: true, music: nil) + } else { + AppState.shared.status?.battery.level = level + } + } + case BLEConstants.charNotificationData, BLEConstants.charMediaState, BLEConstants.charClipboardDataNotify, BLEConstants.charDeviceName, BLEConstants.charNotificationDismissNotify, BLEConstants.charMacControl: + handleChunkedUpdate(uuid: characteristic.uuid, data: data) + default: + break + } + } + + private func handleChunkedUpdate(uuid: CBUUID, data: Data) { + guard let (current, total) = BLEChunkUtil.parseHeader(from: data) else { return } + let payload = BLEChunkUtil.getPayload(from: data) + + var buffer = chunkBuffers[uuid] ?? [:] + buffer[current] = payload + chunkBuffers[uuid] = buffer + + if buffer.count == total { + let completePayload = BLEChunkUtil.reassemble(chunks: buffer) + print("[BLE] Received complete chunked payload for \(uuid)") + chunkBuffers.removeValue(forKey: uuid) + + // Route to BLETransportBridge + BLETransportBridge.shared.handleIncoming(uuid: uuid, payload: completePayload) + } + } +} diff --git a/airsync-mac/Core/BLE/BLEChunkUtil.swift b/airsync-mac/Core/BLE/BLEChunkUtil.swift new file mode 100644 index 00000000..a8688877 --- /dev/null +++ b/airsync-mac/Core/BLE/BLEChunkUtil.swift @@ -0,0 +1,59 @@ +import Foundation + +struct BLEChunkUtil { + + static func splitIntoChunks(payload: String, mtu: Int) -> [Data] { + guard let data = payload.data(using: .utf8) else { return [] } + let maxPayloadSize = mtu - BLEConstants.chunkHeaderSize + + guard maxPayloadSize > 0 else { return [] } + + let totalChunks = Int(ceil(Double(data.count) / Double(maxPayloadSize))) + var chunks: [Data] = [] + + for i in 0.. String { + let sortedIndices = chunks.keys.sorted() + var combinedData = Data() + + for index in sortedIndices { + if let chunkData = chunks[index] { + combinedData.append(chunkData) + } + } + + return String(data: combinedData, encoding: .utf8) ?? "" + } + + static func parseHeader(from data: Data) -> (current: Int, total: Int)? { + guard data.count >= BLEConstants.chunkHeaderSize else { return nil } + + let current = data.subdata(in: 0..<2).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } + let total = data.subdata(in: 2..<4).withUnsafeBytes { $0.load(as: UInt16.self).bigEndian } + + return (Int(current), Int(total)) + } + + static func getPayload(from data: Data) -> Data { + guard data.count > BLEConstants.chunkHeaderSize else { return Data() } + return data.subdata(in: BLEConstants.chunkHeaderSize..= 2 else { return } + let type = components[0] + let action = components[1] + let value = components.count >= 3 ? components[2] : nil + + DispatchQueue.main.async { + switch type { + case "media": + WebSocketServer.shared.handleMediaControl(action: action) + case "volume": + if action == "vol_set", let value = value, let level = Int(value) { + MacRemoteManager.shared.setVolume(level) + } else { + WebSocketServer.shared.handleVolumeControl(action: action) + } + case "remote": + if action == "manual_disconnect" { + print("[BLE] Received manual disconnect from Android client! Instantly disconnecting...") + BLECentralManager.shared.disconnect() + } else { + self.handleRemoteControl(action, value: value) + } + default: + break + } + } + } + + private func handleRemoteControl(_ action: String, value: String? = nil) { + print("[ble] Received remote control: \(action)\(value.map { ", value: \($0)" } ?? "")") + switch action { + case "arrow_up": MacRemoteManager.shared.simulateKey(.upArrow) + case "arrow_down": MacRemoteManager.shared.simulateKey(.downArrow) + case "arrow_left": MacRemoteManager.shared.simulateKey(.leftArrow) + case "arrow_right": MacRemoteManager.shared.simulateKey(.rightArrow) + case "enter": MacRemoteManager.shared.simulateKey(.enter) + case "space": MacRemoteManager.shared.simulateKey(.space) + case "escape": MacRemoteManager.shared.simulateKey(.escape) + case "lock_screen": MacRemoteManager.shared.lockScreen() + case "screensaver": MacRemoteManager.shared.startScreensaver() + case "brightness_up": MacRemoteManager.shared.increaseBrightness() + case "brightness_down": MacRemoteManager.shared.decreaseBrightness() + // Media: use NowPlayingCLI for reliability (HID simulation is unreliable from BLE context) + case "media_play_pause": NowPlayingCLI.shared.toggle() + case "media_next": NowPlayingCLI.shared.next() + case "media_prev": NowPlayingCLI.shared.previous() + case "vol_up": MacRemoteManager.shared.increaseVolume() + case "vol_down": MacRemoteManager.shared.decreaseVolume() + case "vol_mute": MacRemoteManager.shared.toggleMute() + case "vol_set": + if let value = value, let level = Int(value) { + MacRemoteManager.shared.setVolume(level) + } + default: break + } + } + + private func handleNotification(_ components: [String]) { + let pkg: String + let appName: String + let title: String + let text: String + + if components.count >= 4 { + pkg = components[0] + appName = components[1] + title = components[2] + text = components[3] + } else if components.count >= 3 { + pkg = components[0] + appName = pkg // Fallback to package name + title = components[1] + text = components[2] + } else { + return + } + + let notif = Notification( + title: title, + body: text, + app: appName, + nid: UUID().uuidString, + package: pkg, + priority: "", + actions: [] + ) + + DispatchQueue.main.async { + AppState.shared.addNotification(notif) + } + } + + private func handleMediaState(_ components: [String]) { + guard components.count >= 6 else { return } + let isPlaying = components[0] == "1" + let title = components[1] + let artist = components[2] + let volume = Int(components[3]) ?? 0 + let isMuted = components[4] == "1" + let likeStatus = components[5] + + // Update AppState.status + DispatchQueue.main.async { + let music = DeviceStatus.Music( + isPlaying: isPlaying, + title: title, + artist: artist, + volume: volume, + isMuted: isMuted, + albumArt: "", // No art over BLE + likeStatus: likeStatus, + duration: -1.0, + position: -1.0, + isBuffering: false + ) + + if AppState.shared.status == nil { + AppState.shared.status = DeviceStatus(battery: DeviceStatus.Battery(level: 0, isCharging: false), isPaired: true, music: music) + } else { + AppState.shared.status?.music = music + } + } + } + + private func handleClipboard(_ text: String) { + print("[ble] Received clipboard update: \(text.prefix(20))...") + DispatchQueue.main.async { + // Update clipboard if sync enabled + if AppState.shared.isClipboardSyncEnabled { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + } + } + } + + private func handleDeviceName(_ name: String) { + print("[ble] Received device name: \(name)") + DispatchQueue.main.async { + BLECentralManager.shared.connectedDeviceName = name + if AppState.shared.device != nil { + AppState.shared.device?.name = name + } + } + } + + private func handleNotificationDismiss(_ id: String) { + print("[ble] Received notification dismissal: \(id)") + DispatchQueue.main.async { + AppState.shared.removeNotificationById(id) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id]) + } + } + + // --- Outbound --- + + func sendMediaControl(_ action: String) { + BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charMediaControl, payload: action) + } + + func sendMacMediaState(info: NowPlayingInfo) { + let payload = [ + (info.isPlaying ?? false) ? "1" : "0", + info.title ?? "", + info.artist ?? "", + String(MacRemoteManager.shared.lastVolumeLevel), + (MacRemoteManager.shared.lastVolumeLevel == 0) ? "1" : "0", + "none" + ].joined(separator: BLEConstants.delimiter) + + BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charMacMediaState, payload: payload) + } + + func sendClipboard(_ text: String) { + BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charClipboardDataWrite, payload: text) + } +} diff --git a/airsync-mac/Core/Media/NowPlayingPublisher.swift b/airsync-mac/Core/Media/NowPlayingPublisher.swift new file mode 100644 index 00000000..360caaa1 --- /dev/null +++ b/airsync-mac/Core/Media/NowPlayingPublisher.swift @@ -0,0 +1,246 @@ +// +// NowPlayingPublisher.swift +// AirSync +// +// Publishes Android now-playing info into macOS MPNowPlayingInfoCenter +// so boring.notch (via MediaRemote.framework) picks it up naturally. +// Uses silent audio to make the app audio-eligible for MediaRemote reporting. +// + +import Foundation +import AppKit +import AVFoundation +import MediaPlayer + +final class NowPlayingPublisher { + static let shared = NowPlayingPublisher() + + // MARK: - Silent Audio Engine (makes app audio-eligible for MediaRemote) + private var audioEngine: AVAudioEngine? + private var playerNode: AVAudioPlayerNode? + private var isSilentAudioRunning: Bool = false + + // MARK: - State + private var currentInfo: NowPlayingInfo? + private var commandCenterRegistered = false + + /// Timestamp of the last remote command we sent to Android. + private var lastCommandSentAt: Date = .distantPast + /// Timestamp of the last time we updated MPNowPlayingInfoCenter. + private var lastStateUpdateAt: Date = .distantPast + + // Short debounces to provide an instant UI while preventing macOS feedback loops: + // 0.35s limits how fast the user can mash buttons, and blocks automated + // counter-commands that macOS fires right after we update the info center. + private let commandDebounceInterval: TimeInterval = 0.35 + private let stateUpdateDebounceInterval: TimeInterval = 0.35 + + private init() {} + + // MARK: - Public API + + /// Call once at app startup. Registers remote commands only. + /// Silent audio is started/stopped separately based on the showInControlCenter setting. + func start() { + registerRemoteCommands() + } + + /// Starts silent audio if not already running (called when showInControlCenter is enabled). + func enableSilentAudio() { + startSilentAudio() + } + + /// Stops silent audio and clears Now Playing info (called when showInControlCenter is disabled). + func disableSilentAudio() { + clear() + } + + /// Update now-playing with Android media info. + /// During the 1-second window after the user clicks a button, we ignore incoming + /// status updates. This protects our instant optimistic UI from being overwritten + /// by stale network packets that Android dispatched before the command took effect. + func update(info: NowPlayingInfo) { + let timeSinceCommand = Date().timeIntervalSince(lastCommandSentAt) + if timeSinceCommand < 1.0 { + return + } + + currentInfo = info + + // Always publish metadata on the main thread (MPNowPlayingInfoCenter requirement) + DispatchQueue.main.async { + self.lastStateUpdateAt = Date() + self.publishToNowPlayingInfoCenter(info: info) + } + // Silent audio is always running (started in start()), nothing to do here. + } + + /// Clear now-playing info (e.g., Android disconnected) + func clear() { + currentInfo = nil + stopSilentAudio() + DispatchQueue.main.async { + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + MPNowPlayingInfoCenter.default().playbackState = .stopped + } + } + + // MARK: - Silent Audio + + func startSilentAudio() { + guard !isSilentAudioRunning else { return } + isSilentAudioRunning = true + + let engine = AVAudioEngine() + let player = AVAudioPlayerNode() + engine.attach(player) + engine.connect(player, to: engine.mainMixerNode, format: nil) + + // Generate one second of silence + let format = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 2)! + let frameCount = AVAudioFrameCount(format.sampleRate) + guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCount) else { + isSilentAudioRunning = false + return + } + buffer.frameLength = frameCount + // Buffer is already zeroed by default — silence + + // BUG FIX: engine.start() MUST come before player.play() / scheduleBuffer. + // Calling player.play() on an un-started engine produces: + // "Engine is not running because it was not explicitly started" + do { + try engine.start() + } catch { + print("[NowPlayingPublisher] Failed to start silent audio engine: \(error)") + isSilentAudioRunning = false + return + } + + audioEngine = engine + playerNode = player + + player.scheduleBuffer(buffer, at: nil, options: .loops) + player.play() + + print("[NowPlayingPublisher] Silent audio engine started — app is now audio-eligible") + } + + func stopSilentAudio() { + guard isSilentAudioRunning else { return } + playerNode?.stop() + audioEngine?.stop() + audioEngine?.reset() + audioEngine = nil + playerNode = nil + isSilentAudioRunning = false + print("[NowPlayingPublisher] Silent audio engine stopped") + } + + // MARK: - Publish to MPNowPlayingInfoCenter + + private func publishToNowPlayingInfoCenter(info: NowPlayingInfo) { + let center = MPNowPlayingInfoCenter.default() + + var mpInfo: [String: Any] = [ + MPMediaItemPropertyTitle: info.title ?? "", + MPMediaItemPropertyArtist: info.artist ?? "", + MPMediaItemPropertyAlbumTitle: info.album ?? "", + ] + + if let duration = info.duration, duration > 0 { + mpInfo[MPMediaItemPropertyPlaybackDuration] = duration + } + if let elapsed = info.elapsedTime { + mpInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsed + } + mpInfo[MPNowPlayingInfoPropertyPlaybackRate] = info.isPlaying == true ? 1.0 : 0.0 + + if let artworkData = info.artworkData, + let nsImage = NSImage(data: artworkData) { + let artwork = MPMediaItemArtwork(boundsSize: CGSize(width: nsImage.size.width, height: nsImage.size.height)) { _ in + return nsImage + } + mpInfo[MPMediaItemPropertyArtwork] = artwork + } + + center.nowPlayingInfo = mpInfo + // Restore playbackState so UI elements like boringNotch know it's explicitly playing/paused. + // Automated counter-commands triggered by this change will be dropped by stateUpdateDebounceInterval. + center.playbackState = info.isPlaying == true ? .playing : .paused + } + + // MARK: - Remote Commands + + private func processCommand(name: String, action: String, optimisticUpdate: ((NowPlayingPublisher) -> Void)? = nil) -> MPRemoteCommandHandlerStatus { + let now = Date() + let timeSinceCommand = now.timeIntervalSince(lastCommandSentAt) + let timeSinceState = now.timeIntervalSince(lastStateUpdateAt) + + if timeSinceCommand < commandDebounceInterval { + return .success + } + if timeSinceState < stateUpdateDebounceInterval { + return .success + } + + lastCommandSentAt = now + WebSocketServer.shared.sendAndroidMediaControl(action: action) + optimisticUpdate?(self) + + return .success + } + + private func registerRemoteCommands() { + guard !commandCenterRegistered else { return } + commandCenterRegistered = true + + let commandCenter = MPRemoteCommandCenter.shared() + + // NOTE: Commands are forwarded to Android via WebSocket (not NowPlayingCLI which + // controls LOCAL Mac media via the `media-control` binary). This music is from + // the phone, so control actions must go back over the WebSocket connection. + // IMPORTANT: macOS often fires automated counter-commands when we update MPNowPlayingInfoCenter. + // We drop any commands received within `stateUpdateDebounceInterval` of our last update. + // We also do optimistic updates so the UI responds instantly to clicks. + + commandCenter.playCommand.addTarget { [weak self] _ in + return self?.processCommand(name: "Play", action: "play") { $0.publishPlaybackStateUpdate(playing: true) } ?? .commandFailed + } + + commandCenter.pauseCommand.addTarget { [weak self] _ in + return self?.processCommand(name: "Pause", action: "pause") { $0.publishPlaybackStateUpdate(playing: false) } ?? .commandFailed + } + + commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in + let isPlaying = self?.currentInfo?.isPlaying == true + let explicitAction = isPlaying ? "pause" : "play" + return self?.processCommand(name: "TogglePlayPause", action: explicitAction) { publisher in + publisher.publishPlaybackStateUpdate(playing: !isPlaying) + } ?? .commandFailed + } + + commandCenter.nextTrackCommand.addTarget { [weak self] _ in + return self?.processCommand(name: "NextTrack", action: "nextTrack") ?? .commandFailed + } + + commandCenter.previousTrackCommand.addTarget { [weak self] _ in + return self?.processCommand(name: "PreviousTrack", action: "previousTrack") ?? .commandFailed + } + + // Seeking not yet supported for Android remote + commandCenter.changePlaybackPositionCommand.isEnabled = false + + print("[NowPlayingPublisher] Remote commands registered") + } + + private func publishPlaybackStateUpdate(playing: Bool) { + guard var info = currentInfo else { return } + info.isPlaying = playing + currentInfo = info + DispatchQueue.main.async { + self.lastStateUpdateAt = Date() + self.publishToNowPlayingInfoCenter(info: info) + } + } +} diff --git a/airsync-mac/Core/MenuBarManager.swift b/airsync-mac/Core/MenuBarManager.swift index f9443ed1..8b163d50 100644 --- a/airsync-mac/Core/MenuBarManager.swift +++ b/airsync-mac/Core/MenuBarManager.swift @@ -18,6 +18,7 @@ class MenuBarManager: NSObject { private var cancellables = Set() private var appState = AppState.shared private var temporaryDragLabel: String? + private var hostingView: ClickThroughHostingView? private let statusButton: MenuBarStatusButton = { let view = MenuBarStatusButton(frame: NSRect(x: 0, y: 0, width: 22, height: 22)) @@ -49,6 +50,23 @@ class MenuBarManager: NSObject { statusButton.bottomAnchor.constraint(equalTo: button.bottomAnchor) ]) + // Set up ClickThroughHostingView for SwiftUI custom status bar rendering + let hostedView = MenubarStatusView() + let hosting = ClickThroughHostingView(rootView: hostedView) + button.addSubview(hosting) + hosting.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hosting.leadingAnchor.constraint(equalTo: button.leadingAnchor), + hosting.trailingAnchor.constraint(equalTo: button.trailingAnchor), + hosting.topAnchor.constraint(equalTo: button.topAnchor), + hosting.bottomAnchor.constraint(equalTo: button.bottomAnchor) + ]) + self.hostingView = hosting + + // Make sure the native button has no title/image overlay + button.image = nil + button.title = "" + updateStatusItem() } } @@ -59,83 +77,74 @@ class MenuBarManager: NSObject { private func setupBindings() { // Update menu bar when appState changes - Publishers.Merge5( - appState.$device.map { _ in () }, - appState.$notifications.map { _ in () }, - appState.$status.map { _ in () }, - appState.$showMenubarText.map { _ in () }, - appState.$showingQuickShareTransfer.map { _ in () } - ) - .receive(on: RunLoop.main) - .sink { [weak self] in - self?.updateStatusItem() - } - .store(in: &cancellables) + let group1: [AnyPublisher] = [ + appState.$device.map { _ in () }.eraseToAnyPublisher(), + appState.$notifications.map { _ in () }.eraseToAnyPublisher(), + appState.$status.map { _ in () }.eraseToAnyPublisher(), + appState.$showMenubarText.map { _ in () }.eraseToAnyPublisher(), + appState.$showingQuickShareTransfer.map { _ in () }.eraseToAnyPublisher(), + appState.$showMenubarIcon.map { _ in () }.eraseToAnyPublisher(), + appState.$showMenubarCallDetails.map { _ in () }.eraseToAnyPublisher(), + appState.$activeCall.map { _ in () }.eraseToAnyPublisher(), + appState.$activeCallDurationSec.map { _ in () }.eraseToAnyPublisher() + ] + + let group2: [AnyPublisher] = [ + appState.$menubarBatteryStyle.map { _ in () }.eraseToAnyPublisher(), + appState.$showMenubarMusicIcon.map { _ in () }.eraseToAnyPublisher(), + appState.$showMenubarAlbumArt.map { _ in () }.eraseToAnyPublisher(), + appState.$menubarUnreadBadgeStyle.map { _ in () }.eraseToAnyPublisher(), + appState.$menubarUnreadBadgeColor.map { _ in () }.eraseToAnyPublisher(), + appState.$showMenubarDeviceName.map { _ in () }.eraseToAnyPublisher(), + appState.$menubarTextMaxLength.map { _ in () }.eraseToAnyPublisher(), + appState.$menubarFontSize.map { _ in () }.eraseToAnyPublisher() + ] + let group3: [AnyPublisher] = [ + appState.$temporaryDragLabel.map { _ in () }.eraseToAnyPublisher(), + appState.$showMenubarPillStroke.map { _ in () }.eraseToAnyPublisher(), + appState.$menubarNotificationStyle.map { _ in () }.eraseToAnyPublisher(), + BLECentralManager.shared.$connectionStatus.map { _ in () }.eraseToAnyPublisher(), + BLECentralManager.shared.$connectedDeviceName.map { _ in () }.eraseToAnyPublisher() + ] + + Publishers.MergeMany(group1) + .merge(with: Publishers.MergeMany(group2)) + .merge(with: Publishers.MergeMany(group3)) + .receive(on: RunLoop.main) + .sink { [weak self] in + DispatchQueue.main.async { + self?.updateStatusItem() + } + } + .store(in: &cancellables) } func updateStatusItem() { - guard let button = statusItem?.button else { return } - - // Update icon based on state - let iconName = appState.device != nil - ? (appState.notifications.isEmpty ? "iphone.gen3" : "iphone.gen3.radiowaves.left.and.right") - : "iphone.slash" + guard let button = statusItem?.button, let hostingView = hostingView else { return } - button.image = NSImage(systemSymbolName: iconName, accessibilityDescription: "AirSync") - button.imagePosition = .imageLeft + button.image = nil + button.title = "" - // Update text if enabled - if let dragLabel = temporaryDragLabel { - button.title = dragLabel - } else if appState.showMenubarText, let text = getDeviceStatusText() { - button.title = text - } else { - button.title = "" - } + let fittingSize = hostingView.fittingSize + statusItem?.length = max(22, fittingSize.width) } func showDragLabel(_ label: String) { temporaryDragLabel = label + appState.temporaryDragLabel = label updateStatusItem() } func clearDragLabel() { temporaryDragLabel = nil + appState.temporaryDragLabel = nil updateStatusItem() } private func getDeviceStatusText() -> String? { - guard let device = appState.device else { return nil } - - let unreadCount = appState.notifications.count - let unreadPrefix = unreadCount > 0 ? "\(unreadCount)* • " : "" - - if let music = appState.status?.music, music.isPlaying { - let title = music.title.isEmpty ? "Unknown Title" : music.title - let artist = music.artist.isEmpty ? "Unknown Artist" : music.artist - let fullText = unreadPrefix + "\(title) • \(artist)" - return truncate(text: fullText) - } else { - var parts: [String] = [] - if appState.showMenubarDeviceName { - parts.append(device.name) - } - - if let batteryLevel = appState.status?.battery.level { - parts.append("\(batteryLevel)%") - } - let statusText = parts.isEmpty ? nil : parts.joined(separator: " • ") - return statusText.map { truncate(text: unreadPrefix + $0) } - } - } - - private func truncate(text: String) -> String { - let maxLength = appState.menubarTextMaxLength - if text.count > maxLength { - return String(text.prefix(maxLength - 1)) + "…" - } - return text + // Kept for backward compatibility/reference but handled by SwiftUI view + return nil } func togglePopover() { @@ -278,3 +287,253 @@ class MenuBarStatusButton: NSView { return false } } + +// MARK: - Click-Through Hosting View Subclass +class ClickThroughHostingView: NSHostingView { + override func hitTest(_ point: NSPoint) -> NSView? { + return nil + } +} + +// MARK: - Menubar Status View +struct MenubarStatusView: View { + @ObservedObject var appState = AppState.shared + @ObservedObject var bleManager = BLECentralManager.shared + + var body: some View { + HStack(spacing: 6) { + let isConnected = appState.device != nil || bleManager.isAuthenticated + + // 1. Primary Icon + if appState.showMenubarIcon { + let iconName = isConnected ? "iphone.gen3" : "iphone.slash" + Image(systemName: iconName) + .font(.system(size: appState.menubarFontSize)) + .imageScale(.medium) + } + + if isConnected { + // 2. Status Text / Details + if appState.showMenubarText { + if let dragLabel = appState.temporaryDragLabel { + Text(dragLabel) + .font(.system(size: appState.menubarFontSize, weight: .medium)) + } else { + HStack(spacing: 5) { + // Left part: Device Name or Music Info + let showMusic = appState.showMenubarMusicIcon && (appState.status?.music?.isPlaying ?? false) + + if appState.showMenubarCallDetails, let callEvent = appState.activeCall { + HStack(spacing: 4) { + if let photoString = callEvent.contactPhoto, + !photoString.isEmpty, + let data = Data(base64Encoded: photoString, options: .ignoreUnknownCharacters) ?? Data(base64Encoded: photoString), + let nsImage = NSImage(data: data) { + let avatarSize = appState.menubarFontSize + 2 + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + } else { + Image(systemName: callEvent.direction == .incoming ? "phone.arrow.down.left.fill" : "phone.arrow.up.right.fill") + .font(.system(size: appState.menubarFontSize)) + .foregroundColor(.green) + } + + Text(callEvent.contactName) + .font(.system(size: appState.menubarFontSize, weight: .medium)) + .lineLimit(1) + + Text("•") + .font(.system(size: appState.menubarFontSize)) + .foregroundColor(.secondary) + + Text(formatCallDuration(seconds: appState.activeCallDurationSec)) + .font(.system(size: appState.menubarFontSize, design: .monospaced)) + .layoutPriority(1) + } + } else if showMusic, let music = appState.status?.music { + let title = music.title.isEmpty ? "Unknown Title" : music.title + let artist = music.artist.isEmpty ? "Unknown Artist" : music.artist + let musicText = truncate(text: "\(title) - \(artist)") + + HStack(spacing: 3) { + if appState.showMenubarAlbumArt, + !music.albumArt.isEmpty, + let data = Data(base64Encoded: music.albumArt.stripBase64Prefix()) ?? Data(base64Encoded: music.albumArt), + let nsImage = NSImage(data: data) { + let albumArtSize = appState.menubarFontSize + 2 + let cornerRadius = albumArtSize * 3 / 14 + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: albumArtSize, height: albumArtSize) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + } else { + Image(systemName: "music.note") + .font(.system(size: appState.menubarFontSize)) + .foregroundColor(.accentColor) + } + Text(musicText) + .font(.system(size: appState.menubarFontSize)) + } + } else if appState.showMenubarDeviceName { + let deviceName = appState.device?.name ?? (bleManager.isAuthenticated ? bleManager.connectedDeviceName : nil) ?? "" + if !deviceName.isEmpty { + Text(truncate(text: deviceName)) + .font(.system(size: appState.menubarFontSize, weight: .medium)) + } + } + + // Right part: Battery + if let battery = appState.status?.battery { + let style = appState.menubarBatteryStyle + HStack(spacing: 3) { + // Show separator if there was a prefix shown + let hasPrefix = showMusic || (appState.showMenubarDeviceName && !(appState.device?.name ?? bleManager.connectedDeviceName ?? "").isEmpty) + if hasPrefix { + Text("•") + .font(.system(size: appState.menubarFontSize)) + .foregroundColor(.secondary) + } + + if style == "icon" || style == "both" { + Image(systemName: getBatteryIconName(level: battery.level, isCharging: battery.isCharging)) + .font(.system(size: appState.menubarFontSize)) + .foregroundColor(batteryColor(level: battery.level, isCharging: battery.isCharging)) + } + + if style == "percentage" || style == "both" { + Text("\(battery.level)%") + .font(.system(size: appState.menubarFontSize - 1, design: .monospaced)) + } + } + } + } + } + } + + // 3. Unread Badge Count + if appState.menubarNotificationStyle == "both" || appState.menubarNotificationStyle == "count" { + let unreadCount = appState.notifications.count + if unreadCount > 0 { + if appState.menubarUnreadBadgeStyle == "badge" { + Text("\(unreadCount)") + .font(.system(size: appState.menubarFontSize - 3, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .padding(.horizontal, max(4, appState.menubarFontSize * 5 / 12)) + .padding(.vertical, max(1, appState.menubarFontSize * 1 / 12)) + .background(badgeColor) + .clipShape(Capsule()) + } else if appState.menubarUnreadBadgeStyle == "text" { + Text("\(unreadCount)*") + .font(.system(size: appState.menubarFontSize - 1, weight: .semibold)) + .foregroundColor(.secondary) + } + } + } + + // 4. Recent Notification Icons + if appState.menubarNotificationStyle == "both" || appState.menubarNotificationStyle == "icons" { + let recentPackages = appState.recentNotifyingPackages + if !recentPackages.isEmpty { + HStack(spacing: 4) { + ForEach(recentPackages, id: \.self) { package in + let appIconSize = appState.menubarFontSize + 2 + let appCornerRadius = appIconSize * 3 / 14 + if let path = appState.androidApps[package]?.iconUrl, + let image = Image(filePath: path) { + image + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: appIconSize, height: appIconSize) + .clipShape(RoundedRectangle(cornerRadius: appCornerRadius)) + } else { + Image(systemName: "app.badge") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: appIconSize, height: appIconSize) + .foregroundColor(.secondary) + } + } + } + } + } + } + } + .padding(.horizontal, appState.showMenubarPillStroke ? 8 : 4) + .frame(height: 22) + .background( + Group { + if appState.showMenubarPillStroke { + let hasCall = appState.activeCall != nil + Capsule() + .stroke( + hasCall ? Color.accentColor : Color.primary.opacity(0.18), + lineWidth: hasCall ? 2.0 : 1.0 + ) + } + } + ) + } + + + + private var badgeColor: Color { + switch appState.menubarUnreadBadgeColor { + case "accent": return .accentColor + case "red": return .red + case "orange": return .orange + case "blue": return .blue + case "green": return .green + case "purple": return .purple + case "gray": return .gray + default: return .accentColor + } + } + + private func batteryColor(level: Int, isCharging: Bool) -> Color { + if level < 20 { + return .yellow + } else { + return .primary + } + } + + private func getBatteryIconName(level: Int, isCharging: Bool) -> String { + if isCharging { + return "battery.100.bolt" + } else if level >= 88 { + return "battery.100" + } else if level >= 62 { + return "battery.75" + } else if level >= 38 { + return "battery.50" + } else if level >= 12 { + return "battery.25" + } else { + return "battery.0" + } + } + + private func formatCallDuration(seconds: Int) -> String { + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + let secs = seconds % 60 + + if hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, secs) + } else { + return String(format: "%02d:%02d", minutes, secs) + } + } + + private func truncate(text: String) -> String { + let maxLength = appState.menubarTextMaxLength + if text.count > maxLength { + return String(text.prefix(maxLength - 1)) + "…" + } + return text + } +} diff --git a/airsync-mac/Core/QuickShare/InboundNearbyConnection.swift b/airsync-mac/Core/QuickShare/InboundNearbyConnection.swift index 674ddf6f..53da6852 100644 --- a/airsync-mac/Core/QuickShare/InboundNearbyConnection.swift +++ b/airsync-mac/Core/QuickShare/InboundNearbyConnection.swift @@ -22,6 +22,7 @@ public class InboundNearbyConnection: NearbyConnection{ private var currentState:State = .initial public var delegate:InboundNearbyConnectionDelegate? private var cipherCommitment:Data? + internal var completedURLs: [URL] = [] private var textPayloadID:Int64=0 @@ -126,6 +127,7 @@ public class InboundNearbyConnection: NearbyConnection{ try? fileInfo.fileHandle?.close() transferredFiles[id]!.fileHandle=nil fileInfo.progress?.unpublish() + completedURLs.append(fileInfo.destinationURL) transferredFiles.removeValue(forKey: id) if transferredFiles.isEmpty{ delegate?.transferDidComplete(connection: self) @@ -150,6 +152,7 @@ public class InboundNearbyConnection: NearbyConnection{ try fileInfo.fileHandle?.close() transferredFiles[id]!.fileHandle=nil fileInfo.progress?.unpublish() + completedURLs.append(fileInfo.destinationURL) transferredFiles.removeValue(forKey: id) try sendDisconnectionAndDisconnect() return true diff --git a/airsync-mac/Core/QuickShare/NearbyConnectionManager.swift b/airsync-mac/Core/QuickShare/NearbyConnectionManager.swift index 26ff5f69..c67d47ce 100644 --- a/airsync-mac/Core/QuickShare/NearbyConnectionManager.swift +++ b/airsync-mac/Core/QuickShare/NearbyConnectionManager.swift @@ -174,7 +174,7 @@ public protocol MainAppDelegate{ func obtainUserConsent(for transfer:TransferMetadata, from device:RemoteDeviceInfo) func incomingTransfer(id:String, didFinishWith error:Error?) func incomingTransferProgress(id:String, progress:Double) - func transferDidComplete(id:String) + func transferDidComplete(id:String, urls:[URL]) } public protocol ShareExtensionDelegate:AnyObject{ @@ -286,7 +286,7 @@ public class NearbyConnectionManager : NSObject, NetServiceDelegate, InboundNear public func transferDidComplete(connection: InboundNearbyConnection) { guard let delegate=mainAppDelegate else {return} - delegate.transferDidComplete(id: connection.id) + delegate.transferDidComplete(id: connection.id, urls: connection.completedURLs) } public func submitUserConsent(transferID:String, accept:Bool){ diff --git a/airsync-mac/Core/QuickShare/QuickShareManager.swift b/airsync-mac/Core/QuickShare/QuickShareManager.swift index 22619c1e..2ad2de02 100644 --- a/airsync-mac/Core/QuickShare/QuickShareManager.swift +++ b/airsync-mac/Core/QuickShare/QuickShareManager.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI import UserNotifications @preconcurrency import Combine +import UniformTypeIdentifiers struct QuickShareTransferInfo { let device: RemoteDeviceInfo @@ -236,17 +237,33 @@ public class QuickShareManager: NSObject, ObservableObject, MainAppDelegate, Sha activeIncomingTransfers.removeValue(forKey: id) } - public func transferDidComplete(id: String) { - print("[quickshare] Transfer \(id) completed on disk") + public func transferDidComplete(id: String, urls: [URL]) { + print("[quickshare] Transfer \(id) completed on disk with urls: \(urls)") self.transferState = .finished self.transferProgress = 1.0 + // Pop up overlay if enabled and exactly one file transferred + if AppState.shared.popupSharedImages, urls.count == 1, let firstURL = urls.first { + DispatchQueue.main.async { + SharedImagePopupManager.shared.show(fileURL: firstURL) + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { AppState.shared.showingQuickShareTransfer = false self.transferState = .idle } } + private func isImage(url: URL) -> Bool { + if let type = UTType(filenameExtension: url.pathExtension) { + return type.conforms(to: .image) + } + let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "heic", "webp"] + let ext = url.pathExtension.lowercased() + return imageExtensions.contains(ext) + } + public func handleUserConsent(transferID: String, accepted: Bool) { NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: accepted) if !accepted { diff --git a/airsync-mac/Core/QuickShare/SharedImagePopupManager.swift b/airsync-mac/Core/QuickShare/SharedImagePopupManager.swift new file mode 100644 index 00000000..c25948aa --- /dev/null +++ b/airsync-mac/Core/QuickShare/SharedImagePopupManager.swift @@ -0,0 +1,733 @@ +// +// SharedImagePopupManager.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-05-21. +// + +import SwiftUI +import AppKit +import Foundation +import UniformTypeIdentifiers +import Combine +import ImageIO +import AVFoundation + +// MARK: - File Type Classification + +enum SharedFileType { + case image + case video + case other +} + +func getSharedFileType(for url: URL) -> SharedFileType { + let ext = url.pathExtension.lowercased() + let imageExts = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "heic", "webp", "avif", "svg"] + let videoExts = ["mp4", "mov", "m4v", "avi", "mkv", "wmv", "flv", "webm", "ts", "3gp"] + if imageExts.contains(ext) { return .image } + if videoExts.contains(ext) { return .video } + if let uti = UTType(filenameExtension: ext) { + if uti.conforms(to: .image) { return .image } + if uti.conforms(to: .movie) || uti.conforms(to: .video) { return .video } + } + return .other +} + + +// MARK: - High Performance Image Helpers + +func generateLowQualityThumbnail(at url: URL, maxPixelSize: CGFloat) -> NSImage? { + guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else { return nil } + return NSImage(cgImage: cgImage, size: .zero) +} + +// MARK: - Video Thumbnail Helper + +func generateVideoThumbnail(at url: URL, maxPixelSize: CGFloat) -> NSImage? { + let asset = AVURLAsset(url: url) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + generator.maximumSize = CGSize(width: maxPixelSize, height: maxPixelSize) + + let times: [CMTime] = [CMTime(seconds: 1, preferredTimescale: 600), .zero] + for t in times { + if let cg = try? generator.copyCGImage(at: t, actualTime: nil) { + return NSImage(cgImage: cg, size: .zero) + } + } + return nil +} + + +// MARK: - File Metadata Helpers + +func getFileSizeString(at url: URL) -> String { + let values = try? url.resourceValues(forKeys: [.fileSizeKey]) + guard let bytes = values?.fileSize else { return "" } + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(bytes)) +} + +func getFileIconAndColor(for url: URL) -> (String, Color) { + let ext = url.pathExtension.lowercased() + switch ext { + case "pdf": return ("doc.richtext", .red) + case "zip", "rar", "7z", "tar", "gz": return ("archivebox", .orange) + case "mp3", "aac", "flac", "wav", "ogg", "m4a": return ("waveform", .purple) + case "txt", "md", "rtf": return ("doc.text", .blue) + case "csv", "xlsx", "xls", "numbers": return ("tablecells", Color(red: 0.2, green: 0.7, blue: 0.3)) + case "doc", "docx", "pages": return ("doc.fill", .blue) + case "ppt", "pptx", "key": return ("rectangle.on.rectangle", .orange) + case "dmg", "pkg", "app": return ("shippingbox", .gray) + case "swift", "kt", "py", "js", "ts", "html", "css", "json", "xml": return ("chevron.left.forwardslash.chevron.right", .cyan) + case "apk": return ("iphone.gen1", Color(red: 0.4, green: 0.8, blue: 0.4)) + default: + if let uti = UTType(filenameExtension: ext) { + if uti.conforms(to: .audio) { return ("waveform", .purple) } + if uti.conforms(to: .archive) { return ("archivebox", .orange) } + } + return ("doc", .secondary) + } +} + +// MARK: - Data Model + +public struct SharedImageInfo: Identifiable, Equatable { + public let id: UUID + public let fileURL: URL + public let addedAt: Date + + public init(id: UUID = UUID(), fileURL: URL, addedAt: Date = Date()) { + self.id = id + self.fileURL = fileURL + self.addedAt = addedAt + } +} + +// MARK: - Custom AppKit Drag & Drop View Wrapper + +struct FileDraggableView: NSViewRepresentable { + let fileURL: URL + let thumbnailImage: NSImage? + let onDragStarted: () -> Void + let onDragEnded: (Bool) -> Void + + func makeNSView(context: Context) -> DraggableNSView { + let view = DraggableNSView() + view.fileURL = fileURL + view.thumbnailImage = thumbnailImage + view.onDragStarted = onDragStarted + view.onDragEnded = onDragEnded + return view + } + + func updateNSView(_ nsView: DraggableNSView, context: Context) { + nsView.fileURL = fileURL + nsView.thumbnailImage = thumbnailImage + } + + class DraggableNSView: NSView, NSDraggingSource { + var fileURL: URL? + var thumbnailImage: NSImage? + var onDragStarted: (() -> Void)? + var onDragEnded: ((Bool) -> Void)? + + override func mouseDown(with event: NSEvent) { + guard let fileURL = fileURL else { return } + + DispatchQueue.main.async { + self.onDragStarted?() + } + + let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL) + + // Use pre-rendered thumbnail if available, otherwise fallback to workspace icon + var dragImage: NSImage = thumbnailImage ?? NSWorkspace.shared.icon(forFile: fileURL.path) + let maxDragSize = NSSize(width: 120, height: 120) + if dragImage.size.width > maxDragSize.width || dragImage.size.height > maxDragSize.height { + let ratio = dragImage.size.width / dragImage.size.height + let newSize: NSSize = ratio > 1 + ? NSSize(width: maxDragSize.width, height: maxDragSize.width / ratio) + : NSSize(width: maxDragSize.height * ratio, height: maxDragSize.height) + let resized = NSImage(size: newSize) + resized.lockFocus() + dragImage.draw(in: NSRect(origin: .zero, size: newSize), from: .zero, operation: .copy, fraction: 1.0) + resized.unlockFocus() + dragImage = resized + } + + draggingItem.setDraggingFrame( + NSRect(x: event.locationInWindow.x - dragImage.size.width / 2, + y: event.locationInWindow.y - dragImage.size.height / 2, + width: dragImage.size.width, + height: dragImage.size.height), + contents: dragImage + ) + + self.beginDraggingSession(with: [draggingItem], event: event, source: self) + } + + func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { + return .copy + } + + func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + DispatchQueue.main.async { [weak self] in + let success = operation.rawValue != 0 + self?.onDragEnded?(success) + } + } + } +} + +// MARK: - Manager + +@MainActor +public class SharedImagePopupManager: NSObject, ObservableObject { + public static let shared = SharedImagePopupManager() + + @Published public var activeImages: [SharedImageInfo] = [] + + private var window: NSPanel? + private var dismissTimer: Timer? + + private var windowHeight: CGFloat { + let count = activeImages.count + if count <= 1 { + return 200 + } else { + return CGFloat(130 + (count - 1) * 80 + 40) + } + } + + private override init() { + super.init() + } + + public func show(fileURL: URL) { + let limit = AppState.shared.sharedImagePopupsLimit + + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + while self.activeImages.count >= limit { + self.activeImages.removeFirst() + } + let newImage = SharedImageInfo(fileURL: fileURL) + self.activeImages.append(newImage) + } + + self.resetTimer() + + if self.window == nil { + let windowWidth: CGFloat = 300 + let h = self.windowHeight + + let screen = NSScreen.main ?? NSScreen.screens.first + let screenFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1920, height: 1080) + + let onLeft = AppState.shared.popupSharedImagesOnLeft + let targetX = onLeft ? screenFrame.minX : (screenFrame.maxX - windowWidth) + let targetY = screenFrame.midY - (h / 2) + + let startFrame = NSRect(x: targetX, y: targetY, width: windowWidth, height: h) + + let panel = NSPanel( + contentRect: startFrame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.alphaValue = 1.0 + + let hostingView = NSHostingView(rootView: SharedImageOverlayView()) + hostingView.frame = NSRect(x: 0, y: 0, width: windowWidth, height: h) + panel.contentView = hostingView + + self.window = panel + panel.orderFrontRegardless() + } else { + self.updateWindowPosition() + } + } + + public func dismiss(imageID: UUID) { + withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) { + self.activeImages.removeAll { $0.id == imageID } + } + + if self.activeImages.isEmpty { + self.cancelTimer() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + guard let self = self else { return } + if self.activeImages.isEmpty { + self.window?.close() + self.window = nil + } + } + } + } + + public func dismissAll() { + self.cancelTimer() + withAnimation(.spring(response: 0.35, dampingFraction: 0.7)) { + self.activeImages.removeAll() + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + guard let self = self else { return } + if self.activeImages.isEmpty { + self.window?.close() + self.window = nil + } + } + } + + public func updateWindowPosition() { + guard let panel = self.window else { return } + let windowWidth: CGFloat = 300 + let h = self.windowHeight + + let screen = NSScreen.main ?? NSScreen.screens.first + let screenFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1920, height: 1080) + + let onLeft = AppState.shared.popupSharedImagesOnLeft + let targetX = onLeft ? screenFrame.minX : (screenFrame.maxX - windowWidth) + let targetY = screenFrame.midY - (h / 2) + + panel.setFrame(NSRect(x: targetX, y: targetY, width: windowWidth, height: h), display: true, animate: true) + + if let hostingView = panel.contentView as? NSHostingView { + hostingView.frame = NSRect(x: 0, y: 0, width: windowWidth, height: h) + } + } + + private func resetTimer() { + self.cancelTimer() + self.dismissTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: false) { [weak self] _ in + Task { @MainActor in + self?.dismissAll() + } + } + } + + private func cancelTimer() { + self.dismissTimer?.invalidate() + self.dismissTimer = nil + } +} + +// MARK: - SwiftUI Main Overlay View + +struct SharedImageOverlayView: View { + @ObservedObject var manager = SharedImagePopupManager.shared + @ObservedObject var appState = AppState.shared + @State private var isDeckHovered = false + @State private var hoveredCardID: UUID? = nil + + var body: some View { + let onLeft = appState.popupSharedImagesOnLeft + let isExpanded = isDeckHovered || hoveredCardID != nil + + ZStack(alignment: .bottom) { + // Narrow edge strip — only the visible peeking area triggers deck expansion. + // Placed BEHIND the cards so it does not block clicks, drags, or card-specific hover. + if !manager.activeImages.isEmpty { + HStack(spacing: 0) { + if !onLeft { Spacer(minLength: 0).allowsHitTesting(false) } + Rectangle() + .fill(Color.clear) + .frame(width: 45) // Placed behind, generous 45px strip makes hover trigger incredibly easy and reliable + .contentShape(Rectangle()) + .onHover { hovering in + withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { + self.isDeckHovered = hovering + } + } + if onLeft { Spacer(minLength: 0).allowsHitTesting(false) } + } + .frame(width: 300) + .frame(maxHeight: .infinity) + .allowsHitTesting(true) + } + + if !manager.activeImages.isEmpty { + let images = manager.activeImages + let count = images.count + + ForEach(Array(images.enumerated()), id: \.element.id) { index, image in + SharedImageCardView( + image: image, + index: index, + totalCount: count, + isDeckHovered: isExpanded, + onLeft: onLeft, + onHoverChanged: { isHovering in + if isHovering { + hoveredCardID = image.id + } else if hoveredCardID == image.id { + hoveredCardID = nil + } + }, + onDismiss: { + manager.dismiss(imageID: image.id) + } + ) + .transition(.asymmetric( + insertion: .move(edge: onLeft ? .leading : .trailing).combined(with: .opacity), + removal: .move(edge: onLeft ? .leading : .trailing).combined(with: .opacity) + )) + } + } + } + .frame(width: 300) + .frame(maxHeight: .infinity) + } +} + +// MARK: - SwiftUI Individual Card View + +struct SharedImageCardView: View { + let image: SharedImageInfo + let index: Int + let totalCount: Int + let isDeckHovered: Bool + let onLeft: Bool + let onHoverChanged: (Bool) -> Void + let onDismiss: () -> Void + + @State private var isHovered = false + @State private var isPressed = false + @State private var fileType: SharedFileType = .other + @State private var thumbnailImage: NSImage? = nil + @State private var fileSizeString: String = "" + @State private var isLoading = true + + private var fileName: String { + image.fileURL.lastPathComponent + } + + var body: some View { + HStack(spacing: 0) { + if onLeft { + cardContent + .contentShape(RoundedRectangle(cornerRadius: 16)) + .onHover { hovering in + withAnimation(.spring(response: 0.4, dampingFraction: 0.65)) { + self.isHovered = hovering + } + onHoverChanged(hovering) + } + .padding(.leading, -offsetXBase) + .padding(.bottom, -offsetY) + Spacer(minLength: 0).allowsHitTesting(false) + } else { + Spacer(minLength: 0).allowsHitTesting(false) + cardContent + .contentShape(RoundedRectangle(cornerRadius: 16)) + .onHover { hovering in + withAnimation(.spring(response: 0.4, dampingFraction: 0.65)) { + self.isHovered = hovering + } + onHoverChanged(hovering) + } + .padding(.trailing, -offsetXBase) + .padding(.bottom, -offsetY) + } + } + .frame(width: 300) + .frame(maxHeight: .infinity, alignment: .bottom) + .zIndex(Double(index)) + .animation(.spring(response: 0.4, dampingFraction: 0.65), value: isHovered) + .animation(.spring(response: 0.3, dampingFraction: 0.6), value: isPressed) + .animation(.spring(response: 0.4, dampingFraction: 0.7), value: isDeckHovered) + .onAppear { + DispatchQueue.global(qos: .userInteractive).async { + let detectedType = getSharedFileType(for: image.fileURL) + let sizeStr = getFileSizeString(at: image.fileURL) + var thumb: NSImage? = nil + + switch detectedType { + case .image: + thumb = generateLowQualityThumbnail(at: image.fileURL, maxPixelSize: 300) + case .video: + thumb = generateVideoThumbnail(at: image.fileURL, maxPixelSize: 300) + case .other: + break + } + + DispatchQueue.main.async { + self.fileType = detectedType + self.thumbnailImage = thumb + self.fileSizeString = sizeStr + self.isLoading = false + } + } + } + } + + private var cardContent: some View { + ZStack(alignment: .topTrailing) { + cardBody + .frame(width: cardWidth, height: cardHeight) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.white.opacity(0.18), lineWidth: 1.5) + ) + + // Overlay drag layer + FileDraggableView( + fileURL: image.fileURL, + thumbnailImage: thumbnailImage, + onDragStarted: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { + self.isPressed = true + } + }, + onDragEnded: { success in + withAnimation(.spring(response: 0.4, dampingFraction: 0.65)) { + self.isPressed = false + } + if success { + onDismiss() + } + } + ) + .frame(width: cardWidth, height: cardHeight) + + // Action buttons — above drag layer + if isHovered { + HStack(spacing: 6) { + Button(action: { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.writeObjects([image.fileURL as NSURL]) + onDismiss() + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 8, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 18, height: 18) + .background(Color.black.opacity(0.6)) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + } + .buttonStyle(PlainButtonStyle()) + .help(L("quickshare.copy")) + + Button(action: { onDismiss() }) { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(.white) + .frame(width: 18, height: 18) + .background(Color.black.opacity(0.6)) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.white.opacity(0.2), lineWidth: 1)) + } + .buttonStyle(PlainButtonStyle()) + .help(L("notifications.actions.dismiss")) + } + .padding(8) + .transition(.scale.combined(with: .opacity)) + .zIndex(10) + } + } + .rotationEffect(.degrees(rotation), anchor: onLeft ? .bottomLeading : .bottomTrailing) + } + + @ViewBuilder + private var cardBody: some View { + switch fileType { + case .image: + imageCardBody + case .video: + videoCardBody + case .other: + fileCardBody + } + } + + // MARK: Image card + @ViewBuilder + private var imageCardBody: some View { + ZStack(alignment: .bottom) { + if let thumbnail = thumbnailImage { + Image(nsImage: thumbnail) + .resizable() + .aspectRatio(contentMode: .fill) + } else if isLoading { + Color(NSColor.windowBackgroundColor) + .overlay(ProgressView().controlSize(.small)) + } else { + Color(NSColor.windowBackgroundColor) + .overlay( + Image(systemName: "photo") + .font(.largeTitle) + .foregroundColor(.secondary) + ) + } + + // Filename label at bottom + fileNameLabel + } + } + + // MARK: Video card + @ViewBuilder + private var videoCardBody: some View { + ZStack(alignment: .bottom) { + if let thumbnail = thumbnailImage { + Image(nsImage: thumbnail) + .resizable() + .aspectRatio(contentMode: .fill) + } else if isLoading { + Color.black + .overlay(ProgressView().controlSize(.small).tint(.white)) + } else { + Color.black + } + + // Play overlay + Image(systemName: "play.circle.fill") + .font(.system(size: 32)) + .foregroundStyle(.white.opacity(0.85), .black.opacity(0.4)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + fileNameLabel + } + } + + // MARK: Generic file card — solid opaque background, no transparency + @ViewBuilder + private var fileCardBody: some View { + let (iconName, iconColor) = getFileIconAndColor(for: image.fileURL) + ZStack(alignment: .bottom) { + // Solid dark background — never transparent + Color(NSColor.windowBackgroundColor) + + // Subtle color tint gradient + LinearGradient( + colors: [iconColor.opacity(0.25), iconColor.opacity(0.05)], + startPoint: .top, + endPoint: .bottom + ) + + VStack(spacing: 10) { + Spacer() + + // Icon + Image(systemName: iconName) + .font(.system(size: 40, weight: .regular)) + .foregroundColor(iconColor) + + // File size + if !fileSizeString.isEmpty { + Text(fileSizeString) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + fileNameLabel + } + } + + // MARK: Filename label (always visible, always at bottom) + private var fileNameLabel: some View { + VStack(spacing: 0) { + Spacer() + Text(truncatedFileName) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .frame(maxWidth: .infinity) + .background( + LinearGradient( + colors: [Color.black.opacity(0), Color.black.opacity(0.65)], + startPoint: .top, + endPoint: .bottom + ) + ) + } + } + + private var truncatedFileName: String { + // Truncate middle if longer than ~24 chars to fit card width + let name = fileName + let limit = 26 + guard name.count > limit else { return name } + let ext = (name as NSString).pathExtension + let base = (name as NSString).deletingPathExtension + let halfLen = (limit - ext.count - 4) / 2 + if halfLen > 3 { + let start = base.prefix(halfLen) + let end = base.suffix(halfLen) + return "\(start)...\(end).\(ext)" + } + return String(name.prefix(limit)) + "..." + } + + // All cards are a fixed 1:1 square — consistent hitbox regardless of content type + private let cardSize: CGFloat = 130 + private var cardWidth: CGFloat { cardSize } + private var cardHeight: CGFloat { cardSize } + + private var baseY: CGFloat { + let shiftIndex = totalCount - 1 - index + let verticalSpacing = isDeckHovered ? 80.0 : 15.0 + return -CGFloat(shiftIndex) * verticalSpacing + } + + private var baseRotation: Double { + let shiftIndex = totalCount - 1 - index + let base = 8.0 + Double(shiftIndex) * 3.0 + return onLeft ? base : -base + } + + private var rotation: Double { + if isPressed { return 0.0 } + if isHovered { return onLeft ? 14.0 : -14.0 } + return baseRotation + } + + private var offsetXBase: CGFloat { + if isPressed { + return 15 + } else if isHovered { + return 15 + } else if isDeckHovered { + let shiftIndex = totalCount - 1 - index + return max(10, 60 - CGFloat(shiftIndex) * 25) + } else { + let shiftIndex = totalCount - 1 - index + return 75 + CGFloat(shiftIndex) * 8 + } + } + + private var offsetY: CGFloat { + let bottomPadding: CGFloat = 18.0 + if isPressed { return baseY - bottomPadding } + if isHovered { return baseY - 20 - bottomPadding } + return baseY - bottomPadding + } +} diff --git a/airsync-mac/Core/Remote/Scrcpy/ScrcpyServerManager.swift b/airsync-mac/Core/Remote/Scrcpy/ScrcpyServerManager.swift index fff240f6..560f1d37 100644 --- a/airsync-mac/Core/Remote/Scrcpy/ScrcpyServerManager.swift +++ b/airsync-mac/Core/Remote/Scrcpy/ScrcpyServerManager.swift @@ -10,7 +10,7 @@ import Foundation class ScrcpyServerManager: NSObject { static let shared = ScrcpyServerManager() - private let serverLocalPath = Bundle.main.path(forResource: "scrcpy-server-v3.3.4", ofType: nil) ?? "/Users/sameerasandakelum/GIT/airsync-mac/scrcpy-server-v3.3.4" + private let serverLocalPath = Bundle.main.path(forResource: "scrcpy-server", ofType: nil) ?? "/Users/sameerasandakelum/GIT/airsync-mac/scrcpy-server" private let serverRemotePath = "/data/local/tmp/scrcpy-server" private let serverPort: Int = 1234 @@ -104,7 +104,7 @@ class ScrcpyServerManager: NSObject { "-s", serial, "shell", "CLASSPATH=\(serverRemotePath)", - "app_process", "/", "com.genymobile.scrcpy.Server", "3.3.4", + "app_process", "/", "com.genymobile.scrcpy.Server", "4.0", "tunnel_forward=true", "audio=false", "video=true", "control=true", "video_codec=h265", "video_bit_rate=8000000", "max_size=1440" ] diff --git a/airsync-mac/Core/Remote/Scrcpy/ScrcpyStreamClient.swift b/airsync-mac/Core/Remote/Scrcpy/ScrcpyStreamClient.swift index ced83940..d3a4f969 100644 --- a/airsync-mac/Core/Remote/Scrcpy/ScrcpyStreamClient.swift +++ b/airsync-mac/Core/Remote/Scrcpy/ScrcpyStreamClient.swift @@ -111,23 +111,15 @@ class ScrcpyStreamClient: ObservableObject { self?.deviceName = deviceName } - // Read 12-byte metadata: [4: Codec][4: Width][4: Height] - self?.connection?.receive(minimumIncompleteLength: 12, maximumLength: 12) { [weak self] data, context, isComplete, error in - guard let data = data, data.count == 12, error == nil else { - print("[ScrcpyStreamClient] Failed to read codec/width/height") + // Read 4-byte metadata: [4: Codec] + self?.connection?.receive(minimumIncompleteLength: 4, maximumLength: 4) { [weak self] data, context, isComplete, error in + guard let data = data, data.count == 4, error == nil else { + print("[ScrcpyStreamClient] Failed to read codec") return } - let codec = data.subdata(in: 0..<4).withUnsafeBytes { $0.load(as: UInt32.self).byteSwapped } - let width = data.subdata(in: 4..<8).withUnsafeBytes { $0.load(as: UInt32.self).byteSwapped } - let height = data.subdata(in: 8..<12).withUnsafeBytes { $0.load(as: UInt32.self).byteSwapped } - - print("[ScrcpyStreamClient] Codec: 0x\(String(format: "%08X", codec)), Resolution: \(width)x\(height)") - - DispatchQueue.main.async { - self?.videoWidth = width - self?.videoHeight = height - } + let codec = data.withUnsafeBytes { $0.load(as: UInt32.self).byteSwapped } + print("[ScrcpyStreamClient] Codec: 0x\(String(format: "%08X", codec))") self?.readFrameHeader() } @@ -136,7 +128,7 @@ class ScrcpyStreamClient: ObservableObject { } private func readFrameHeader() { - // Read 12-byte frame header: [8: PTS][4: Size] + // Read 12-byte packet header connection?.receive(minimumIncompleteLength: 12, maximumLength: 12) { [weak self] data, context, isComplete, error in guard let data = data, data.count == 12, error == nil else { if let error = error { @@ -145,12 +137,36 @@ class ScrcpyStreamClient: ObservableObject { return } - let pts = data.subdata(in: 0..<8).withUnsafeBytes { $0.load(as: UInt64.self).byteSwapped } - let packetSize = data.subdata(in: 8..<12).withUnsafeBytes { $0.load(as: UInt32.self).byteSwapped } - - // Note: Modern scrcpy does not send config/keyframe flags in the header. - // We'll let the decoder handle Annex-B parsing for keyframes and SPS/PPS. - self?.readPacket(size: Int(packetSize), isConfig: false, isKeyframe: false, pts: pts) + // Check if MSB of the first byte is set to 1 (Session Packet) + if (data[0] & 0x80) != 0 { + // Session Packet: [4: Flags (MSB=1)][4: Width][4: Height] + let flags = data.subdata(in: 0..<4).withUnsafeBytes { $0.load(as: UInt32.self).byteSwapped } + let width = data.subdata(in: 4..<8).withUnsafeBytes { $0.load(as: UInt32.self).byteSwapped } + let height = data.subdata(in: 8..<12).withUnsafeBytes { $0.load(as: UInt32.self).byteSwapped } + + print("[ScrcpyStreamClient] Session Packet: Flags: 0x\(String(format: "%08X", flags)), Resolution: \(width)x\(height)") + + DispatchQueue.main.async { + self?.videoWidth = width + self?.videoHeight = height + } + + // Immediately read the next packet header (no packet payload follows a session packet) + self?.readFrameHeader() + } else { + // Media Packet: [8: PTS][4: Size] + // Note: The two most significant bits of PTS contain flags: + // Bit 62 is SC_PACKET_FLAG_CONFIG, Bit 61 is SC_PACKET_FLAG_KEY_FRAME. + // The actual PTS is stored in the remaining 62 bits. + let ptsWithFlags = data.subdata(in: 0..<8).withUnsafeBytes { $0.load(as: UInt64.self).byteSwapped } + let isConfig = (ptsWithFlags & (1 << 62)) != 0 + let isKeyframe = (ptsWithFlags & (1 << 61)) != 0 + let pts = ptsWithFlags & 0x1FFFFFFFFFFFFFFF + + let packetSize = data.subdata(in: 8..<12).withUnsafeBytes { $0.load(as: UInt32.self).byteSwapped } + + self?.readPacket(size: Int(packetSize), isConfig: isConfig, isKeyframe: isKeyframe, pts: pts) + } } } diff --git a/airsync-mac/Core/Storage/UserDefaults.swift b/airsync-mac/Core/Storage/UserDefaults.swift index 345a6dc6..24d56122 100644 --- a/airsync-mac/Core/Storage/UserDefaults.swift +++ b/airsync-mac/Core/Storage/UserDefaults.swift @@ -14,8 +14,7 @@ extension UserDefaults { static let consecutiveLicenseFailCount = "consecutiveLicenseFailCount" static let consecutiveNetworkFailureDays = "consecutiveNetworkFailureDays" static let scrcpyOnTop = "scrcpyOnTop" - static let scrcpyShareRes = "scrcpyShareRes" - static let scrcpyDesktopMode = "scrcpyDesktopMode" + static let scrcpyDesktopDpi = "scrcpyDesktopDpi" static let lastADBCommand = "lastADBCommand" static let stayAwake = "stayAwake" static let turnScreenOff = "turnScreenOff" @@ -26,8 +25,13 @@ extension UserDefaults { static let continueApp = "continueApp" static let directKeyInput = "directKeyInput" static let sendNowPlayingStatus = "sendNowPlayingStatus" + static let syncAndroidPlaybackSeekbar = "syncAndroidPlaybackSeekbar" + static let showInControlCenter = "showInControlCenter" static let isMusicCardHidden = "isMusicCardHidden" static let lastOnboarding = "lastOnboarding" + static let popupSharedImages = "popupSharedImages" + static let sharedImagePopupsLimit = "sharedImagePopupsLimit" + static let popupSharedImagesOnLeft = "popupSharedImagesOnLeft" static let notificationStacks = "notificationStacks" static let trialToken = "trialToken" @@ -61,14 +65,9 @@ extension UserDefaults { set { set(newValue, forKey: Keys.scrcpyOnTop)} } - var scrcpyShareRes: Bool { - get { bool(forKey: Keys.scrcpyShareRes)} - set { set(newValue, forKey: Keys.scrcpyShareRes)} - } - - var scrcpyDesktopMode: String? { - get { object(forKey: Keys.scrcpyDesktopMode) as? String } - set { set(newValue, forKey: Keys.scrcpyDesktopMode) } + var scrcpyDesktopDpi: String { + get { string(forKey: Keys.scrcpyDesktopDpi) ?? "192" } + set { set(newValue, forKey: Keys.scrcpyDesktopDpi) } } var lastADBCommand: String? { @@ -130,11 +129,45 @@ extension UserDefaults { set { set(newValue, forKey: Keys.sendNowPlayingStatus)} } + /// When enabled, AirSync plays a silent audio loop to claim macOS Now Playing focus, + /// allowing the Android playback seekbar to be exposed in boringNotch / Control Center. + /// Disabled by default because it causes Bluetooth multipoint headphones to route + /// audio to the Mac, preventing Android media from playing through the headphones. + var syncAndroidPlaybackSeekbar: Bool { + get { bool(forKey: Keys.syncAndroidPlaybackSeekbar) } + set { set(newValue, forKey: Keys.syncAndroidPlaybackSeekbar) } + } + + /// Controls whether Android media info is published to macOS Control Center / boringNotch + /// via a silent background audio track. Off by default due to Bluetooth multipoint side-effects. + var showInControlCenter: Bool { + get { bool(forKey: Keys.showInControlCenter) } + set { set(newValue, forKey: Keys.showInControlCenter) } + } + var isMusicCardHidden: Bool { get { bool(forKey: Keys.isMusicCardHidden) } set { set(newValue, forKey: Keys.isMusicCardHidden) } } + var popupSharedImages: Bool { + get { object(forKey: Keys.popupSharedImages) == nil ? true : bool(forKey: Keys.popupSharedImages) } + set { set(newValue, forKey: Keys.popupSharedImages) } + } + + var sharedImagePopupsLimit: Int { + get { + let val = integer(forKey: Keys.sharedImagePopupsLimit) + return val == 0 ? 3 : val + } + set { set(newValue, forKey: Keys.sharedImagePopupsLimit) } + } + + var popupSharedImagesOnLeft: Bool { + get { bool(forKey: Keys.popupSharedImagesOnLeft) } + set { set(newValue, forKey: Keys.popupSharedImagesOnLeft) } + } + var trialToken: String? { get { string(forKey: Keys.trialToken) } set { set(newValue, forKey: Keys.trialToken) } diff --git a/airsync-mac/Core/Storage/WebDAVManager.swift b/airsync-mac/Core/Storage/WebDAVManager.swift new file mode 100644 index 00000000..719b9263 --- /dev/null +++ b/airsync-mac/Core/Storage/WebDAVManager.swift @@ -0,0 +1,91 @@ +// +// WebDAVManager.swift +// airsync-mac +// +// Created by Sameera Sandakelum on 2026-05-21. +// + +import Foundation +import Cocoa + +class WebDAVManager { + static let shared = WebDAVManager() + + private let mountPoint = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Caches/com.airsync.mac/AndroidVolume") + + private var isMounted = false + + private init() {} + + func mount(ipAddress: String, port: Int = 9081, volumeName: String = "Android") { + guard !isMounted else { return } + + let urlString = "http://\(ipAddress):\(port)/" + + DispatchQueue.global(qos: .userInitiated).async { + // 1. Force unmount and clean directory + self.unmountSilently() + + do { + if FileManager.default.fileExists(atPath: self.mountPoint.path) { + try? FileManager.default.removeItem(at: self.mountPoint) + } + try FileManager.default.createDirectory(at: self.mountPoint, withIntermediateDirectories: true) + + // 2. Mount the WebDAV server + let process = Process() + process.executableURL = URL(fileURLWithPath: "/sbin/mount_webdav") + + // WebDAV URLs should have a trailing slash for the root + let finalUrl = urlString.hasSuffix("/") ? urlString : "\(urlString)/" + + print("[webdav] Attempting to mount \(finalUrl) to \(self.mountPoint.path)") + + // Revert to simple command that works + process.arguments = [finalUrl, self.mountPoint.path] + + try process.run() + process.waitUntilExit() + + if process.terminationStatus == 0 { + self.isMounted = true + print("[webdav] Successfully mounted Android volume") + } else { + print("[webdav] Failed to mount WebDAV volume. Status: \(process.terminationStatus)") + } + } catch { + print("[webdav] Error in mount process: \(error)") + } + } + } + + func unmount() { + DispatchQueue.global(qos: .userInitiated).async { + self.unmountSilently() + self.isMounted = false + } + } + + private func unmountSilently() { + // Try diskutil first + let diskutil = Process() + diskutil.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + diskutil.arguments = ["unmount", "force", self.mountPoint.path] + try? diskutil.run() + diskutil.waitUntilExit() + + // Fallback to umount if directory still exists or diskutil failed + let umount = Process() + umount.executableURL = URL(fileURLWithPath: "/sbin/umount") + umount.arguments = ["-f", self.mountPoint.path] + try? umount.run() + umount.waitUntilExit() + } + + func openInFinder() { + if FileManager.default.fileExists(atPath: mountPoint.path) { + NSWorkspace.shared.open(mountPoint) + } + } +} diff --git a/airsync-mac/Core/Util/CLI/ADBConnector.swift b/airsync-mac/Core/Util/CLI/ADBConnector.swift index 5f95f996..a8b76649 100644 --- a/airsync-mac/Core/Util/CLI/ADBConnector.swift +++ b/airsync-mac/Core/Util/CLI/ADBConnector.swift @@ -369,16 +369,15 @@ struct ADBConnector { let bitrate = AppState.shared.scrcpyBitrate let resolution = AppState.shared.scrcpyResolution let wiredAdbEnabled = AppState.shared.wiredAdbEnabled - let desktopMode = UserDefaults.standard.scrcpyDesktopMode let alwaysOnTop = UserDefaults.standard.scrcpyOnTop let stayAwake = UserDefaults.standard.stayAwake let turnScreenOff = UserDefaults.standard.turnScreenOff - let appRes = UserDefaults.standard.scrcpyShareRes ? UserDefaults.standard.scrcpyDesktopMode : "900x2100" let noAudio = UserDefaults.standard.noAudio let manualPosition = UserDefaults.standard.manualPosition let manualPositionCoords = UserDefaults.standard.manualPositionCoords let continueApp = UserDefaults.standard.continueApp let directKeyInput = UserDefaults.standard.directKeyInput + let dpi = UserDefaults.standard.scrcpyDesktopDpi DispatchQueue.global(qos: .userInitiated).async { guard let scrcpyPath = findExecutable(named: "scrcpy", fallbackPaths: possibleScrcpyPaths) else { @@ -396,7 +395,6 @@ struct ADBConnector { "--window-title=\(deviceNameFormatted)", "--video-bit-rate=\(bitrate)M", "--video-codec=h265", - "--max-size=\(resolution)", "--no-power-on" ] @@ -421,14 +419,18 @@ struct ADBConnector { if noAudio { args.append("--no-audio") } if directKeyInput { args.append("--keyboard=uhid") } + let displayArg = "/\(dpi)" + if desktop ?? true { - let res = desktopMode ?? "1600x1000" - let dpi = UserDefaults.standard.string(forKey: "scrcpyDesktopDpi") ?? "" - args.append("--new-display=\(res)" + (!dpi.isEmpty ? "/\(dpi)" : "")) + args.append("--new-display=\(displayArg)") + args.append("-x") + } else if package == nil { + // Only add max-size for regular mirror + args.append("--max-size=\(resolution)") } if let pkg = package { - args.append(contentsOf: ["--new-display=\(appRes ?? "900x2100")", "--start-app=\(pkg)", "--no-vd-system-decorations"]) + args.append(contentsOf: ["--new-display=\(displayArg)", "--start-app=\(pkg)", "--no-vd-system-decorations", "-x"]) if continueApp { args.append("--no-vd-destroy-content") } } diff --git a/airsync-mac/Core/Util/Gumroad.swift b/airsync-mac/Core/Util/Gumroad.swift index de57b80f..19e2c8a2 100644 --- a/airsync-mac/Core/Util/Gumroad.swift +++ b/airsync-mac/Core/Util/Gumroad.swift @@ -146,6 +146,7 @@ class Gumroad { AppState.shared.licenseDetails = nil UserDefaults.standard.removeObject(forKey: "licenseDetailsKey") UserDefaults.standard.consecutiveLicenseFailCount = 0 + UserDefaults.standard.lastLicenseSuccessfulCheckDate = nil } func incrementInvalidLicenseFailCount() { @@ -293,7 +294,8 @@ class Gumroad { func checkLicenseIfNeeded() async { // If we already had a successful check today, skip to enforce "max one successful check per day" - if let lastSuccess = UserDefaults.standard.lastLicenseSuccessfulCheckDate, + if appState.licenseDetails != nil, + let lastSuccess = UserDefaults.standard.lastLicenseSuccessfulCheckDate, Calendar.current.isDateInToday(lastSuccess) { print("[gumroad] License already successfully validated today — skipping network call.") appState.isPlus = true diff --git a/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift index 835bec1c..6b27120a 100644 --- a/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift +++ b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift @@ -9,6 +9,7 @@ import Combine import CryptoKit class MacInfoSyncManager: ObservableObject { + static let shared = MacInfoSyncManager() @Published var title: String = "Unknown Title" @Published var artist: String = "Unknown Artist" @Published var album: String = "Unknown Album" @@ -34,6 +35,8 @@ class MacInfoSyncManager: ObservableObject { let isMuted: Bool let albumArt: String let likeStatus: String + let elapsedTime: Int + let duration: Int } let batteryLevel: Int let isCharging: Bool @@ -94,7 +97,7 @@ class MacInfoSyncManager: ObservableObject { } } - private func fetch() { + func fetch() { // Only fetch if there's a connected device guard AppState.shared.device != nil else { return } @@ -108,6 +111,21 @@ class MacInfoSyncManager: ObservableObject { self?.sendDeviceStatusWithoutMusic() return } + + // IMPORTANT: Filter out AirSync's own bundle ID. + // NowPlayingPublisher writes Android's media info into macOS + // MPNowPlayingInfoCenter so boringNotch can display it. + // media-control reads from the same source, so without this guard + // we'd forward AirSync's own published entry back to Android, + // creating a play/pause feedback loop. + let ownBundleId = Bundle.main.bundleIdentifier ?? "" + if let bundleId = info.bundleIdentifier, !ownBundleId.isEmpty, + bundleId == ownBundleId { + // This is our own reflection — treat as nothing playing on Mac + self?.sendDeviceStatusWithoutMusic() + return + } + // MUST update @Published properties on main thread DispatchQueue.main.async { // print("Now Playing fetched:", info) // debug @@ -216,7 +234,9 @@ class MacInfoSyncManager: ObservableObject { volume: MacRemoteManager.shared.lastVolumeLevel, isMuted: MacRemoteManager.shared.lastVolumeLevel == 0, albumArt: currentHash ?? "", // Use hash for snapshot comparison - likeStatus: "none" // must match payload default + likeStatus: "none", // must match payload default + elapsedTime: Int(info.elapsedTime ?? 0), + duration: Int(info.duration ?? 0) ) }() diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 2d04f6eb..87dbb334 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -166,6 +166,17 @@ extension WebSocketServer { } } + if let bleToken = dict["bleAuthToken"] as? String { + let oldToken = UserDefaults.standard.string(forKey: "bleAuthToken") + UserDefaults.standard.set(bleToken, forKey: "bleAuthToken") + print("[websocket] Received BLE auth token") + + // If token changed or was new, and BLE is enabled, we might want to reconnect + if oldToken != bleToken && AppState.shared.isBLEEnabled { + BLECentralManager.shared.startScanning() + } + } + if UserDefaults.standard.hasPairedDeviceOnce == false { UserDefaults.standard.hasPairedDeviceOnce = true } @@ -254,6 +265,34 @@ extension WebSocketServer { { let albumArt = (music["albumArt"] as? String) ?? "" let likeStatus = (music["likeStatus"] as? String) ?? "none" + let isBuffering = (music["isBuffering"] as? Bool) ?? false + + // Android sends duration/position in ms; convert to seconds. + // Using NSNumber because Swift's `as? Double` fails if the JSON parser inferred an Int. + let durationSec = (music["duration"] as? NSNumber).map { $0.doubleValue / 1000.0 } ?? -1.0 + var positionSec = (music["position"] as? NSNumber).map { $0.doubleValue / 1000.0 } ?? -1.0 + + // Timestamp-based position correction: + // Android includes the wall-clock ms when the position snapshot was taken. + // We add the elapsed time since then (which includes WiFi transit) to get a + // much more accurate "current" position — effectively NTP-style compensation. + // Clamp: only correct for realistic WiFi delays (< 5s). Larger deltas likely + // indicate clock skew between devices, which would worsen accuracy if applied. + if positionSec >= 0, playing, !isBuffering, + let tsMs = music["positionTimestamp"] as? NSNumber { + let capturedAt = tsMs.doubleValue / 1000.0 + let nowSec = Date().timeIntervalSince1970 + let networkDelta = nowSec - capturedAt + if networkDelta > -2.0 && networkDelta < 5.0 { + positionSec += max(0.0, networkDelta) + } + } + // Clamp to duration to prevent the seekbar going past the end + if durationSec > 0 && positionSec > durationSec { + positionSec = durationSec + } + + let oldTitle = AppState.shared.status?.music?.title AppState.shared.status = DeviceStatus( battery: .init(level: level, isCharging: isCharging), @@ -265,9 +304,46 @@ extension WebSocketServer { volume: volume, isMuted: isMuted, albumArt: albumArt, - likeStatus: likeStatus + likeStatus: likeStatus, + duration: durationSec, + position: positionSec, + isBuffering: isBuffering ) ) + + DispatchQueue.main.async { + if oldTitle != title { + AppState.shared.handleTrackChange() + } else { + AppState.shared.syncMediaPosition(incoming: positionSec) + } + } + + // Only publish to macOS Control Center / boringNotch when the user opts in, + // because it requires a silent background audio track which can cause multipoint + // Bluetooth headphones to route audio focus to the Mac. + if UserDefaults.standard.showInControlCenter { + var npInfo = NowPlayingInfo() + npInfo.title = title + npInfo.artist = artist + npInfo.isPlaying = playing + if let data = Data(base64Encoded: albumArt) { + npInfo.artworkData = data + } + // Seekbar: Android sends duration/position in ms; MPNowPlayingInfoCenter needs seconds. + // positionMs uses optDouble so missing/null safely falls back to -1. + // NOTE: Use NSNumber because Swift's JSON parser returns an Int type for flat numbers. + if let nsNum = music["duration"] as? NSNumber, nsNum.doubleValue > 0 { + npInfo.duration = nsNum.doubleValue / 1000.0 + } + if let pMs = music["position"] as? NSNumber, pMs.doubleValue >= 0 { + npInfo.elapsedTime = pMs.doubleValue / 1000.0 + } + NowPlayingPublisher.shared.update(info: npInfo) + } else { + // If the setting is off, ensure any previously running session is cleared + NowPlayingPublisher.shared.clear() + } } } @@ -591,24 +667,34 @@ extension WebSocketServer { } } - private func handleMacMediaControlRequest(_ message: Message) { + func handleMacMediaControlRequest(_ message: Message) { if let dict = message.data.value as? [String: Any], let action = dict["action"] as? String { - handleMacMediaControl(action: action) + handleMediaControl(action: action) } } - private func handleMacMediaControl(action: String) { + func handleMediaControl(action: String) { switch action { case "play": NowPlayingCLI.shared.play() case "pause": NowPlayingCLI.shared.pause() case "previous": NowPlayingCLI.shared.previous() case "next": NowPlayingCLI.shared.next() case "stop": NowPlayingCLI.shared.stop() + case "playPause": NowPlayingCLI.shared.toggle() default: break } sendMacMediaControlResponse(action: action, success: true) } + + func handleVolumeControl(action: String) { + switch action { + case "up", "vol_up": MacRemoteManager.shared.increaseVolume() + case "down", "vol_down": MacRemoteManager.shared.decreaseVolume() + case "mute", "vol_mute": MacRemoteManager.shared.toggleMute() + default: break + } + } private func handleNotificationUpdate(_ message: Message) { if let dict = message.data.value as? [String: Any], diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift index 8a5e091c..d516efd7 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Outgoing.swift @@ -6,6 +6,7 @@ import Foundation import Swifter import CryptoKit +import AppKit extension WebSocketServer { @@ -18,12 +19,13 @@ extension WebSocketServer { activeSessions.forEach { $0.writeText(message) } } - func sendToFirstAvailable(message: String) { + @discardableResult + func sendToFirstAvailable(message: String) -> Bool { lock.lock() guard let pId = primarySessionID, let session = activeSessions.first(where: { ObjectIdentifier($0) == pId }) else { lock.unlock() - return + return false } let key = symmetricKey lock.unlock() @@ -33,6 +35,7 @@ extension WebSocketServer { } else { session.writeText(message) } + return true } private func sendMessage(type: String, data: [String: Any]) { @@ -44,13 +47,100 @@ extension WebSocketServer { do { let jsonData = try JSONSerialization.data(withJSONObject: messageDict, options: []) if let jsonString = String(data: jsonData, encoding: .utf8) { - sendToFirstAvailable(message: jsonString) + let sent = sendToFirstAvailable(message: jsonString) + if !sent && BLECentralManager.shared.isAuthenticated { + sendOverBLE(type: type, data: data) + } } } catch { print("[websocket] Error creating \(type) message: \(error)") } } + private func sendOverBLE(type: String, data: [String: Any]) { + print("[ble] Sending message over BLE: \(type)") + switch type { + case "notificationAction": + if let id = data["id"] as? String, let name = data["name"] as? String { + let text = data["text"] as? String ?? "" + let payload = "\(id)|\(name)|\(text)" + BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charNotificationAction, payload: payload) + } + case "mediaControl": + if let action = data["action"] as? String { + BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charMediaControl, payload: action) + } + case "volumeControl": + if let action = data["action"] as? String { + if action == "setVolume", let volume = data["volume"] as? Int { + let payload = "setVolume|\(volume)" + BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charMediaControl, payload: payload) + } else { + BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charMediaControl, payload: action) + } + } + case "callControl": + if let action = data["action"] as? String { + let bleAction: String + switch action { + case "accept": bleAction = "callAccept" + case "decline": bleAction = "callDecline" + case "end": bleAction = "callEnd" + default: bleAction = action + } + BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charMediaControl, payload: bleAction) + } + case "clipboardUpdate": + if let content = data["content"] as? String { + BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charClipboardDataWrite, payload: content) + } + case "dismissNotification": + if let id = data["id"] as? String { + BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charNotificationDismiss, payload: id) + } + case "status": + // Handle status over BLE + if let battery = data["battery"] as? [String: Any], + let level = battery["level"] as? Int, + let charging = battery["isCharging"] as? Bool { + let payload = [String(level), charging ? "1" : "0"].joined(separator: BLEConstants.delimiter) + BLECentralManager.shared.write(characteristicUUID: BLEConstants.charMacBattery, data: payload.data(using: .utf8)!) + } + // For media status + if let music = data["music"] as? [String: Any] { + let isPlaying = music["isPlaying"] as? Bool ?? false + let title = music["title"] as? String ?? "" + let artist = music["artist"] as? String ?? "" + let volume = music["volume"] as? Int ?? 0 + let isMuted = music["isMuted"] as? Bool ?? false + let likeStatus = music["likeStatus"] as? String ?? "none" + let albumArt = music["albumArtLite"] as? String ?? "" // Use lite version for BLE + + let payload = [ + isPlaying ? "1" : "0", + title, + artist, + String(volume), + isMuted ? "1" : "0", + likeStatus, + albumArt + ].joined(separator: BLEConstants.delimiter) + + BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charMacMediaState, payload: payload) + } + case "toggleAppNotif": + if let package = data["package"] as? String, let state = data["state"] as? String { + let payload = "toggleNotif|\(package)|\(state)" + BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charMediaControl, payload: payload) + } + case "disconnectRequest": + // Maybe handle disconnect? + break + default: + print("[ble] No BLE mapping for type: \(type)") + } + } + // MARK: - Outgoing Requests func sendDisconnectRequest() { @@ -108,8 +198,33 @@ extension WebSocketServer { func like() { sendMediaAction("like") } func unlike() { sendMediaAction("unlike") } + /// Seek Android playback to a specific position (in seconds). + func seekTo(positionSeconds: Double) { + let positionMs = Int(positionSeconds * 1000) + sendMessage(type: "mediaControl", data: ["action": "seekTo", "positionMs": positionMs]) + } + private func sendMediaAction(_ action: String) { sendMessage(type: "mediaControl", data: ["action": action]) + + // Also send via BLE + BLETransportBridge.shared.sendMediaControl(action) + } + + /// Forward a system media command (from MPRemoteCommandCenter) back to Android. + /// - action: "play", "pause", "playPause", "nextTrack", "previousTrack" + func sendAndroidMediaControl(action: String) { + // Map MPRemoteCommandCenter-style names to the Android protocol's action names + let androidAction: String + switch action { + case "play": androidAction = "play" + case "pause": androidAction = "pause" + case "playPause": androidAction = "playPause" + case "nextTrack": androidAction = "next" + case "previousTrack": androidAction = "previous" + default: androidAction = action + } + sendMediaAction(androidAction) } // MARK: - Volume Controls @@ -124,6 +239,14 @@ extension WebSocketServer { private func sendVolumeAction(_ action: String) { sendMessage(type: "volumeControl", data: ["action": action]) + + // Map volume actions to media actions or specific BLE writes if needed + // For now, only media control is explicitly in the BLE protocol + if action == "volumeUp" { + BLETransportBridge.shared.sendMediaControl("volUp") + } else if action == "volumeDown" { + BLETransportBridge.shared.sendMediaControl("volDown") + } } func sendMacVolumeUpdate(level: Int) { @@ -135,7 +258,16 @@ extension WebSocketServer { } func sendClipboardUpdate(_ message: String) { - sendToFirstAvailable(message: message) + let sent = sendToFirstAvailable(message: message) + if !sent && BLECentralManager.shared.isAuthenticated { + // Extract text if possible or just send raw + if let data = message.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let innerData = dict["data"] as? [String: Any], + let text = innerData["text"] as? String { + BLETransportBridge.shared.sendClipboard(text) + } + } } // MARK: - Device Status (Mac -> Android) @@ -153,12 +285,32 @@ extension WebSocketServer { "artist": musicInfo.artist ?? "", "volume": MacRemoteManager.shared.lastVolumeLevel, "isMuted": MacRemoteManager.shared.lastVolumeLevel == 0, - "likeStatus": "none" + "likeStatus": "none", + "elapsedTime": musicInfo.elapsedTime ?? 0, + "duration": musicInfo.duration ?? 0, + "timestamp": musicInfo.timestamp ?? "", + "playbackRate": musicInfo.playbackRate ?? 1.0 ] if let art = albumArtBase64 { musicDict["albumArt"] = art } + + // Create lite version for BLE (scaled down and compressed) + if let artworkData = musicInfo.artworkData, let image = NSImage(data: artworkData) { + let size = NSSize(width: 80, height: 80) + let frame = NSRect(x: 0, y: 0, width: size.width, height: size.height) + if let representation = image.bestRepresentation(for: frame, context: nil, hints: nil) { + let resizedImage = NSImage(size: size, flipped: false, drawingHandler: { (_) -> Bool in + return representation.draw(in: frame) + }) + if let tiff = resizedImage.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let jpegData = bitmap.representation(using: .jpeg, properties: [.compressionFactor: 0.3]) { + musicDict["albumArtLite"] = jpegData.base64EncodedString() + } + } + } statusDict["music"] = musicDict } @@ -170,33 +322,43 @@ extension WebSocketServer { /// Executes a call control action on the Android device via ADB. /// Maps generic actions (accept, end) to specific ADB key events. + func sendMacStatusOverBLE() { + let batteryLevel: Int + let isCharging: Bool + + if let status = BatteryInfo.fetchStatus() { + batteryLevel = status.percentage + isCharging = status.isCharging + } else { + batteryLevel = -1 // Desktop Mac + isCharging = false + } + + let payload = "\(batteryLevel)|\(isCharging ? "1" : "0")" + if let data = payload.data(using: .utf8) { + BLECentralManager.shared.write(characteristicUUID: BLEConstants.charMacBattery, data: data) + } + + // Also send name if we have it + let name = Host.current().localizedName ?? "My Mac" + BLECentralManager.shared.writeChunked(characteristicUUID: BLEConstants.charDeviceName, payload: name) + } + func sendCallAction(eventId: String, action: String) { - let keyCode: String + let commandAction: String switch action.lowercased() { - case "accept": keyCode = "5" - case "decline", "end": keyCode = "6" - default: keyCode = "6" + case "accept": + commandAction = "accept" + case "decline": + commandAction = "decline" + case "end": + commandAction = "end" + default: + commandAction = action.lowercased() } - DispatchQueue.global(qos: .userInitiated).async { - guard let adbPath = ADBConnector.findExecutable(named: "adb", fallbackPaths: ADBConnector.possibleADBPaths) else { return } - - let adbIP = AppState.shared.adbConnectedIP.isEmpty ? AppState.shared.device?.ipAddress ?? "" : AppState.shared.adbConnectedIP - if !adbIP.isEmpty { - let adbPort = AppState.shared.adbPort - let fullAddress = "\(adbIP):\(adbPort)" - let process = Process() - process.executableURL = URL(fileURLWithPath: adbPath) - process.arguments = ["-s", fullAddress, "shell", "input", "keyevent", keyCode] - - do { - try process.run() - process.waitUntilExit() - } catch { - print("[websocket] Failed to send call action: \(error)") - } - } - } + // Natively send programmatic control command over WebSocket / BLE sync channel + sendMessage(type: "callControl", data: ["action": commandAction]) } // MARK: - File Transfer (Mac -> Android) diff --git a/airsync-mac/Info.plist b/airsync-mac/Info.plist index 86b8041e..532b8163 100644 --- a/airsync-mac/Info.plist +++ b/airsync-mac/Info.plist @@ -3,7 +3,7 @@ AndroidVersion - 3.1.0 + 4.0.0 CFBundleDocumentTypes @@ -40,7 +40,7 @@ ForceUpdateKey - 005 + 006 NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/airsync-mac/Localization/af.json b/airsync-mac/Localization/af.json index b7932f93..0427f383 100644 --- a/airsync-mac/Localization/af.json +++ b/airsync-mac/Localization/af.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Kopieer na knipbord", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Lettergrootte" } diff --git a/airsync-mac/Localization/ar.json b/airsync-mac/Localization/ar.json index 3d6624d0..b662570f 100644 --- a/airsync-mac/Localization/ar.json +++ b/airsync-mac/Localization/ar.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "نسخ إلى الحافظة", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "حجم الخط" } diff --git a/airsync-mac/Localization/ca.json b/airsync-mac/Localization/ca.json index 9c9b86fd..013aa8b1 100644 --- a/airsync-mac/Localization/ca.json +++ b/airsync-mac/Localization/ca.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Copia al porta-retalls", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Mida de font" } diff --git a/airsync-mac/Localization/cs.json b/airsync-mac/Localization/cs.json index b7932f93..e6ceeb51 100644 --- a/airsync-mac/Localization/cs.json +++ b/airsync-mac/Localization/cs.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Kopírovat do schránky", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Velikost písma" } diff --git a/airsync-mac/Localization/da.json b/airsync-mac/Localization/da.json index b7932f93..cb517ffc 100644 --- a/airsync-mac/Localization/da.json +++ b/airsync-mac/Localization/da.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Kopier til udklipsholder", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Skriftstørrelse" } diff --git a/airsync-mac/Localization/de.json b/airsync-mac/Localization/de.json index 34f70f97..fcc31905 100644 --- a/airsync-mac/Localization/de.json +++ b/airsync-mac/Localization/de.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "In die Zwischenablage kopieren", + "settings.menubar.showIcon": "Menüleistensymbol anzeigen", + "settings.menubar.showText": "Menüleistentext anzeigen", + "settings.menubar.maxLength": "Maximale Länge", + "settings.menubar.showDeviceName": "Gerätenamen anzeigen", + "settings.menubar.showBattery": "Batteriesymbol anzeigen", + "settings.menubar.showMusic": "Jetzt läuft", + "settings.menubar.badgeStyle": "Stil für ungelesene Mitteilungen", + "settings.menubar.badgeStyle.badge": "Standard-Badge", + "settings.menubar.badgeStyle.text": "Einfacher Text", + "settings.menubar.badgeStyle.none": "Keiner", + "settings.menubar.badgeColor": "Badge-Farbe", + "settings.menubar.color.red": "Rot", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blau", + "settings.menubar.color.green": "Grün", + "settings.menubar.color.purple": "Lila", + "settings.menubar.color.gray": "Grau", + "settings.menubar.color.accent": "System-Akzent", + "settings.menubar.showPillStroke": "Rahmen um Menüleisten-Container", + "settings.menubar.showRecentNotifIcons": "Symbole kürzlicher Benachrichtigungen anzeigen", + "settings.menubar.batteryStyle": "Batteriestil", + "settings.menubar.batteryStyle.both": "Sowohl Symbol als auch Prozent", + "settings.menubar.batteryStyle.icon": "Nur Symbol", + "settings.menubar.batteryStyle.percentage": "Nur Prozent", + "settings.menubar.notifications": "Benachrichtigungen", + "settings.menubar.notifications.both": "Sowohl Anzahl als auch Symbole", + "settings.menubar.notifications.count": "Nur Anzahl", + "settings.menubar.notifications.icons": "Nur Symbole", + "settings.menubar.notifications.none": "Keine", + "settings.menubar.showAlbumArt": "Album-Cover anzeigen", + "settings.menubar.fontSize": "Schriftgröße" } diff --git a/airsync-mac/Localization/el.json b/airsync-mac/Localization/el.json index b7932f93..4dae2391 100644 --- a/airsync-mac/Localization/el.json +++ b/airsync-mac/Localization/el.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Αντιγραφή στο πρόχειρο", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Μέγεθος γραμματοσειράς" } diff --git a/airsync-mac/Localization/en.json b/airsync-mac/Localization/en.json index b7932f93..695ad8f6 100644 --- a/airsync-mac/Localization/en.json +++ b/airsync-mac/Localization/en.json @@ -1,6 +1,7 @@ { "app.name": "AirSync", "menu.mirror": "Mirror", + "menu.browseFiles": "Browse Files", "menu.about": "About AirSync", "menu.checkUpdates": "Check for Updates…", "menu.quit": "Quit", @@ -16,6 +17,12 @@ "apps.tab": "Apps", "transfers.tab": "Transfers", "settings.tab": "Settings", + "settings.myMac": "My Mac", + "settings.sync": "Sync", + "settings.mirroring": "Mirroring", + "settings.menubar": "Menubar", + "settings.appearance": "Appearance & more", + "settings.airsyncPlus": "AirSync+", "qr.tab": "Scan QR", "transfers.empty": "Nothing transferred yet", "plus.title": "AirSync+", @@ -39,6 +46,11 @@ "quickshare.error.crypto": "Encryption error", "quickshare.transferError": "Transfer error with %@", "quickshare.settings.autoAccept": "Auto accept from my device", + "quickshare.settings.popupSharedImages": "Shared file pop-ups", + "quickshare.settings.maxPopups": "Max pop-up cards", + "quickshare.settings.popupSide": "Pop up position", + "quickshare.settings.side.left": "Left", + "quickshare.settings.side.right": "Right", "quickshare.waiting_for": "Waiting for %1$@...", "quickshare.select_device": "Select a device", "quickshare.searching": "Searching for nearby devices...", @@ -55,5 +67,76 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share and Files", + "settings.fileAccess.title": "File Access", + "settings.fileAccess.enabled": "Enable File Access", + "settings.fileAccess.description": "Mount your Android device storage as a local drive in macOS Finder automatically.", + "settings.mirroring.scrcpy.title": "scrcpy Mirror", + "settings.mirroring.scrcpy.description": "scrcpy is a reliable integration built-in to AirSync", + "settings.mirroring.native.title": "Android Mirror (BETA)", + "settings.mirroring.native.description": "As close as you can get to iPhone Mirroring XD. Powered by scrcpy. WIP", + "settings.mirroring.defaultMode": "Default mode", + "settings.mirroring.native.info": "Native Android Mirroring powered by scrcpy to feel just like iPhone Mirroring. WIP. You can always right click the mirror button for other options.", + "settings.mirroring.scrcpy.info": "Integration with scrcpy to provide reliable Android Mirroring. You can always right click the mirror button for other options.", + "settings.mirroring.appMirroring": "App Mirroring", + "settings.mirroring.enableAppMirroring": "Enable App Mirroring", + "settings.mirroring.videoBitrate": "Video bitrate", + "settings.mirroring.bitrateFormat": "%d Mbps", + "settings.mirroring.maxSize": "Max size", + "settings.mirroring.stayOnTop": "Stay on top", + "settings.mirroring.stayAwake": "Stay awake (charging)", + "settings.mirroring.blankDisplay": "Blank display", + "settings.mirroring.noAudio": "No audio", + "settings.mirroring.continueApp": "Continue app after closing", + "settings.mirroring.directKeyboardInput": "Direct keyboard input", + "settings.mirroring.dpi": "dpi", + "settings.mirroring.manualPosition": "Manual launch position (x,y)", + "settings.mirroring.x": "x", + "settings.mirroring.y": "y", + "settings.mirroring.set": "Set", + "settings.mirroring.plusFeatureMessage": "App Mirroring is an AirSync+ feature", + "quickshare.copy": "Copy to clipboard", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Font size", + "settings.menubar.albumArt.plusFeatureMessage": "Show Album Art is an AirSync+ feature", + "settings.menubar.notifications.plusFeatureMessage": "Notification styles are available in AirSync+", + "menubar.call.accept": "Accept", + "menubar.call.decline": "Decline", + "menubar.call.end": "End", + "menubar.call.incomingCall": "Incoming Call", + "menubar.call.outgoingCall": "Outgoing Call", + "menubar.call.ringing": "Ringing...", + "menubar.call.accepted": "Accepted", + "settings.menubar.notifications.calls": "Calls", + "settings.menubar.calls.plusFeatureMessage": "Ongoing call pill details are available in AirSync+", + "settings.autoStartAtLogin": "Auto start at login" } diff --git a/airsync-mac/Localization/es.json b/airsync-mac/Localization/es.json index 26444b66..5bc56489 100644 --- a/airsync-mac/Localization/es.json +++ b/airsync-mac/Localization/es.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Copiar al portapapeles", + "settings.menubar.showIcon": "Mostrar icono de barra de menú", + "settings.menubar.showText": "Mostrar texto de barra de menú", + "settings.menubar.maxLength": "Longitud máxima", + "settings.menubar.showDeviceName": "Mostrar nombre del dispositivo", + "settings.menubar.showBattery": "Mostrar icono de batería", + "settings.menubar.showMusic": "En reproducción", + "settings.menubar.badgeStyle": "Estilo de insignia no leída", + "settings.menubar.badgeStyle.badge": "Insignia estándar", + "settings.menubar.badgeStyle.text": "Texto simple", + "settings.menubar.badgeStyle.none": "Ninguno", + "settings.menubar.badgeColor": "Color de la insignia", + "settings.menubar.color.red": "Rojo", + "settings.menubar.color.orange": "Naranja", + "settings.menubar.color.blue": "Azul", + "settings.menubar.color.green": "Verde", + "settings.menubar.color.purple": "Púrpura", + "settings.menubar.color.gray": "Gris", + "settings.menubar.color.accent": "Acento del sistema", + "settings.menubar.showPillStroke": "Borde de pastilla del contenedor", + "settings.menubar.showRecentNotifIcons": "Mostrar iconos de notificaciones recientes", + "settings.menubar.batteryStyle": "Estilo de batería", + "settings.menubar.batteryStyle.both": "Ambos (Icono y Porcentaje)", + "settings.menubar.batteryStyle.icon": "Solo icono", + "settings.menubar.batteryStyle.percentage": "Solo porcentaje", + "settings.menubar.notifications": "Notificaciones", + "settings.menubar.notifications.both": "Ambos (Recuento e Iconos)", + "settings.menubar.notifications.count": "Solo recuento", + "settings.menubar.notifications.icons": "Solo iconos", + "settings.menubar.notifications.none": "Ninguno", + "settings.menubar.showAlbumArt": "Mostrar portada del álbum", + "settings.menubar.fontSize": "Tamaño de letra" } diff --git a/airsync-mac/Localization/fi.json b/airsync-mac/Localization/fi.json index b7932f93..6d665951 100644 --- a/airsync-mac/Localization/fi.json +++ b/airsync-mac/Localization/fi.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Kopioi leikepöydälle", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Fonttikoko" } diff --git a/airsync-mac/Localization/fr.json b/airsync-mac/Localization/fr.json index 7aa5226e..ab7e2c23 100644 --- a/airsync-mac/Localization/fr.json +++ b/airsync-mac/Localization/fr.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d fichiers", "quickshare.drop.send_to": "Envoyer à %@", "quickshare.drop.pick_device": "Choisissez un appareil auquel envoyer", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Copier dans le presse-papiers", + "settings.menubar.showIcon": "Afficher l'icône de la barre de menus", + "settings.menubar.showText": "Afficher le texte de la barre de menus", + "settings.menubar.maxLength": "Longueur maximale", + "settings.menubar.showDeviceName": "Afficher le nom de l'appareil", + "settings.menubar.showBattery": "Afficher l'icône de batterie", + "settings.menubar.showMusic": "En cours de lecture", + "settings.menubar.badgeStyle": "Style de badge non lu", + "settings.menubar.badgeStyle.badge": "Badge standard", + "settings.menubar.badgeStyle.text": "Texte simple", + "settings.menubar.badgeStyle.none": "Aucun", + "settings.menubar.badgeColor": "Couleur du badge", + "settings.menubar.color.red": "Rouge", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Bleu", + "settings.menubar.color.green": "Vert", + "settings.menubar.color.purple": "Violet", + "settings.menubar.color.gray": "Gris", + "settings.menubar.color.accent": "Accentuation du système", + "settings.menubar.showPillStroke": "Bordure de pilule du conteneur", + "settings.menubar.showRecentNotifIcons": "Afficher les icônes de notification récentes", + "settings.menubar.batteryStyle": "Style de batterie", + "settings.menubar.batteryStyle.both": "Icône et pourcentage", + "settings.menubar.batteryStyle.icon": "Icône uniquement", + "settings.menubar.batteryStyle.percentage": "Pourcentage uniquement", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Nombre et icônes", + "settings.menubar.notifications.count": "Nombre uniquement", + "settings.menubar.notifications.icons": "Icônes uniquement", + "settings.menubar.notifications.none": "Aucune", + "settings.menubar.showAlbumArt": "Afficher la pochette de l'album", + "settings.menubar.fontSize": "Taille de police" } diff --git a/airsync-mac/Localization/he.json b/airsync-mac/Localization/he.json index 5ed9c9f5..555e82ea 100644 --- a/airsync-mac/Localization/he.json +++ b/airsync-mac/Localization/he.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "העתק ללוח", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "גודל גופן" } diff --git a/airsync-mac/Localization/hi.json b/airsync-mac/Localization/hi.json index b7932f93..ccc9e7f9 100644 --- a/airsync-mac/Localization/hi.json +++ b/airsync-mac/Localization/hi.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "क्लिपबोर्ड पर कॉपी करें", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "फ़ॉन्ट आकार" } diff --git a/airsync-mac/Localization/hu.json b/airsync-mac/Localization/hu.json index b7932f93..686e944c 100644 --- a/airsync-mac/Localization/hu.json +++ b/airsync-mac/Localization/hu.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Másolás a vágólapra", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Betűméret" } diff --git a/airsync-mac/Localization/it.json b/airsync-mac/Localization/it.json index cd4e506b..7f7c05d7 100644 --- a/airsync-mac/Localization/it.json +++ b/airsync-mac/Localization/it.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d file", "quickshare.drop.send_to": "Invia a %@", "quickshare.drop.pick_device": "Seleziona un destinatario", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Copia negli appunti", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "In riproduzione", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Mostra icone notifiche recenti", + "settings.menubar.batteryStyle": "Stile batteria", + "settings.menubar.batteryStyle.both": "Entrambi (Icona e Percentuale)", + "settings.menubar.batteryStyle.icon": "Solo icona", + "settings.menubar.batteryStyle.percentage": "Solo percentuale", + "settings.menubar.notifications": "Notifiche", + "settings.menubar.notifications.both": "Entrambi (Conteggio e Icone)", + "settings.menubar.notifications.count": "Solo conteggio", + "settings.menubar.notifications.icons": "Solo icone", + "settings.menubar.notifications.none": "Nessuna", + "settings.menubar.showAlbumArt": "Mostra copertina dell'album", + "settings.menubar.fontSize": "Dimensione carattere" } diff --git a/airsync-mac/Localization/ja.json b/airsync-mac/Localization/ja.json index dac99ec7..cd193f93 100644 --- a/airsync-mac/Localization/ja.json +++ b/airsync-mac/Localization/ja.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "クリップボードにコピー", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "再生中", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "最近の通知アイコンを表示", + "settings.menubar.batteryStyle": "バッテリー表示スタイル", + "settings.menubar.batteryStyle.both": "アイコンとパーセンテージの両方", + "settings.menubar.batteryStyle.icon": "アイコンのみ", + "settings.menubar.batteryStyle.percentage": "パーセンテージのみ", + "settings.menubar.notifications": "通知表示スタイル", + "settings.menubar.notifications.both": "件数とアイコンの両方", + "settings.menubar.notifications.count": "件数のみ", + "settings.menubar.notifications.icons": "アイコンのみ", + "settings.menubar.notifications.none": "なし", + "settings.menubar.showAlbumArt": "アルバムアートを表示", + "settings.menubar.fontSize": "フォントサイズ" } diff --git a/airsync-mac/Localization/ko.json b/airsync-mac/Localization/ko.json index 24b9687c..5cba3bdd 100644 --- a/airsync-mac/Localization/ko.json +++ b/airsync-mac/Localization/ko.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "클립보드에 복사", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "글자 크기" } diff --git a/airsync-mac/Localization/nl.json b/airsync-mac/Localization/nl.json index 3a50b450..2fd25c28 100644 --- a/airsync-mac/Localization/nl.json +++ b/airsync-mac/Localization/nl.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Kopieer naar klembord", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Nu afspelen", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Toon recente meldingpictogrammen", + "settings.menubar.batteryStyle": "Batterijstijl", + "settings.menubar.batteryStyle.both": "Beide (Pictogram en Percentage)", + "settings.menubar.batteryStyle.icon": "Alleen pictogram", + "settings.menubar.batteryStyle.percentage": "Alleen percentage", + "settings.menubar.notifications": "Meldingen", + "settings.menubar.notifications.both": "Beide (Aantal en Pictogrammen)", + "settings.menubar.notifications.count": "Alleen aantal", + "settings.menubar.notifications.icons": "Alleen pictogrammen", + "settings.menubar.notifications.none": "Geen", + "settings.menubar.showAlbumArt": "Albumhoes weergeven", + "settings.menubar.fontSize": "Lettergrootte" } diff --git a/airsync-mac/Localization/no.json b/airsync-mac/Localization/no.json index b7932f93..a85a5e1d 100644 --- a/airsync-mac/Localization/no.json +++ b/airsync-mac/Localization/no.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Kopier til utklippstavle", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Skriftstørrelse" } diff --git a/airsync-mac/Localization/pl.json b/airsync-mac/Localization/pl.json index 2d7265d9..b3a46474 100644 --- a/airsync-mac/Localization/pl.json +++ b/airsync-mac/Localization/pl.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Wyślij do %@", "quickshare.drop.pick_device": "Wybierz urządzenie do którego chcesz wysłać", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Skopiuj do schowka", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Rozmiar czcionki" } diff --git a/airsync-mac/Localization/pt.json b/airsync-mac/Localization/pt.json index 66dcc034..dcc9bc53 100644 --- a/airsync-mac/Localization/pt.json +++ b/airsync-mac/Localization/pt.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Copiar para a área de transferência", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "A reproduzir", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Mostrar ícones de notificações recentes", + "settings.menubar.batteryStyle": "Estilo de bateria", + "settings.menubar.batteryStyle.both": "Ambos (Ícone e Percentagem)", + "settings.menubar.batteryStyle.icon": "Apenas ícone", + "settings.menubar.batteryStyle.percentage": "Apenas percentagem", + "settings.menubar.notifications": "Notificações", + "settings.menubar.notifications.both": "Ambos (Contagem e Ícones)", + "settings.menubar.notifications.count": "Apenas contagem", + "settings.menubar.notifications.icons": "Apenas ícones", + "settings.menubar.notifications.none": "Nenhuma", + "settings.menubar.showAlbumArt": "Mostrar capa do álbum", + "settings.menubar.fontSize": "Tamanho da fonte" } diff --git a/airsync-mac/Localization/ro.json b/airsync-mac/Localization/ro.json index ccf92846..6577a42c 100644 --- a/airsync-mac/Localization/ro.json +++ b/airsync-mac/Localization/ro.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Copiați în clipboard", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Dimensiune font" } diff --git a/airsync-mac/Localization/ru.json b/airsync-mac/Localization/ru.json index 27be330f..171679a7 100644 --- a/airsync-mac/Localization/ru.json +++ b/airsync-mac/Localization/ru.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Копировать в буфер обмена", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Сейчас играет", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Показывать значки недавних уведомлений", + "settings.menubar.batteryStyle": "Стиль батареи", + "settings.menubar.batteryStyle.both": "И то, и другое", + "settings.menubar.batteryStyle.icon": "Только значок", + "settings.menubar.batteryStyle.percentage": "Только процент", + "settings.menubar.notifications": "Уведомления", + "settings.menubar.notifications.both": "И то, и другое", + "settings.menubar.notifications.count": "Только количество", + "settings.menubar.notifications.icons": "Только значки", + "settings.menubar.notifications.none": "Нет", + "settings.menubar.showAlbumArt": "Показывать обложку альбома", + "settings.menubar.fontSize": "Размер шрифта" } diff --git a/airsync-mac/Localization/si.json b/airsync-mac/Localization/si.json index b7932f93..ec9e8e4d 100644 --- a/airsync-mac/Localization/si.json +++ b/airsync-mac/Localization/si.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "ක්ලිප්බෝඩ් එකට කොපි කරන්න", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "අකුරු ප්‍රමාණය" } diff --git a/airsync-mac/Localization/sk.json b/airsync-mac/Localization/sk.json index 85c0af6f..eb3b13d3 100644 --- a/airsync-mac/Localization/sk.json +++ b/airsync-mac/Localization/sk.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Kopírovať do schránky", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Veľkosť písma" } diff --git a/airsync-mac/Localization/sr.json b/airsync-mac/Localization/sr.json index b7932f93..704b8162 100644 --- a/airsync-mac/Localization/sr.json +++ b/airsync-mac/Localization/sr.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Копирај у привремену меморију", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Величина фонта" } diff --git a/airsync-mac/Localization/sv.json b/airsync-mac/Localization/sv.json index b7932f93..3db5a266 100644 --- a/airsync-mac/Localization/sv.json +++ b/airsync-mac/Localization/sv.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Kopiera till urklipp", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Teckenstorlek" } diff --git a/airsync-mac/Localization/tr.json b/airsync-mac/Localization/tr.json index b7932f93..6f6bd202 100644 --- a/airsync-mac/Localization/tr.json +++ b/airsync-mac/Localization/tr.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Panoya kopyala", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Yazı tipi boyutu" } diff --git a/airsync-mac/Localization/uk.json b/airsync-mac/Localization/uk.json index 55e8ccf7..22e587bd 100644 --- a/airsync-mac/Localization/uk.json +++ b/airsync-mac/Localization/uk.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Копіювати в буфер обміну", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Розмір шрифту" } diff --git a/airsync-mac/Localization/vi.json b/airsync-mac/Localization/vi.json index b7932f93..21c21488 100644 --- a/airsync-mac/Localization/vi.json +++ b/airsync-mac/Localization/vi.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "Sao chép vào khay nhớ tạm", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "Now playing", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "Show recent notification icons", + "settings.menubar.batteryStyle": "Battery Style", + "settings.menubar.batteryStyle.both": "Both Icon & Percentage", + "settings.menubar.batteryStyle.icon": "Icon Only", + "settings.menubar.batteryStyle.percentage": "Percentage Only", + "settings.menubar.notifications": "Notifications", + "settings.menubar.notifications.both": "Both Count & Icons", + "settings.menubar.notifications.count": "Count Only", + "settings.menubar.notifications.icons": "Icons Only", + "settings.menubar.notifications.none": "None", + "settings.menubar.showAlbumArt": "Show album art", + "settings.menubar.fontSize": "Cỡ chữ" } diff --git a/airsync-mac/Localization/zh-Hans.json b/airsync-mac/Localization/zh-Hans.json index 8e4d443b..ac2b87bd 100644 --- a/airsync-mac/Localization/zh-Hans.json +++ b/airsync-mac/Localization/zh-Hans.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "复制到剪贴板", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "正在播放", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "显示最近的通知图标", + "settings.menubar.batteryStyle": "电池显示样式", + "settings.menubar.batteryStyle.both": "图标与百分比", + "settings.menubar.batteryStyle.icon": "仅图标", + "settings.menubar.batteryStyle.percentage": "仅百分比", + "settings.menubar.notifications": "通知显示样式", + "settings.menubar.notifications.both": "计数与图标", + "settings.menubar.notifications.count": "仅计数", + "settings.menubar.notifications.icons": "仅图标", + "settings.menubar.notifications.none": "无", + "settings.menubar.showAlbumArt": "显示专辑封面", + "settings.menubar.fontSize": "字体大小" } diff --git a/airsync-mac/Localization/zh-Hant.json b/airsync-mac/Localization/zh-Hant.json index 6571ee13..77468415 100644 --- a/airsync-mac/Localization/zh-Hant.json +++ b/airsync-mac/Localization/zh-Hant.json @@ -55,5 +55,37 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share", + "quickshare.copy": "複製到剪貼簿", + "settings.menubar.showIcon": "Show Menu Bar Icon", + "settings.menubar.showText": "Show Menu Bar Text", + "settings.menubar.maxLength": "Max Length", + "settings.menubar.showDeviceName": "Show Device Name", + "settings.menubar.showBattery": "Show Battery Icon", + "settings.menubar.showMusic": "正在播放", + "settings.menubar.badgeStyle": "Unread Badge Style", + "settings.menubar.badgeStyle.badge": "Standard Badge", + "settings.menubar.badgeStyle.text": "Simple Text", + "settings.menubar.badgeStyle.none": "None", + "settings.menubar.badgeColor": "Badge Color", + "settings.menubar.color.red": "Red", + "settings.menubar.color.orange": "Orange", + "settings.menubar.color.blue": "Blue", + "settings.menubar.color.green": "Green", + "settings.menubar.color.purple": "Purple", + "settings.menubar.color.gray": "Gray", + "settings.menubar.color.accent": "System Accent", + "settings.menubar.showPillStroke": "Container Pill Border", + "settings.menubar.showRecentNotifIcons": "顯示最近的通知圖標", + "settings.menubar.batteryStyle": "電池顯示樣式", + "settings.menubar.batteryStyle.both": "圖標與百分比", + "settings.menubar.batteryStyle.icon": "僅圖標", + "settings.menubar.batteryStyle.percentage": "僅百分比", + "settings.menubar.notifications": "通知顯示樣式", + "settings.menubar.notifications.both": "計數與圖標", + "settings.menubar.notifications.count": "僅計數", + "settings.menubar.notifications.icons": "僅圖標", + "settings.menubar.notifications.none": "無", + "settings.menubar.showAlbumArt": "顯示專輯封面", + "settings.menubar.fontSize": "字型大小" } diff --git a/airsync-mac/Model/Device.swift b/airsync-mac/Model/Device.swift index d4528c41..d2631958 100644 --- a/airsync-mac/Model/Device.swift +++ b/airsync-mac/Model/Device.swift @@ -10,7 +10,7 @@ import Foundation struct Device: Codable, Hashable, Identifiable { let id = UUID() - let name: String + var name: String let ipAddress: String let port: Int let version: String @@ -47,7 +47,10 @@ struct MockData{ volume: 50, isMuted: false, albumArt: "", - likeStatus: "none" + likeStatus: "none", + duration: 214, + position: 42, + isBuffering: false ) static let sampleDevices = [ diff --git a/airsync-mac/Model/DeviceStatus.swift b/airsync-mac/Model/DeviceStatus.swift index 767b5e12..38b672d6 100644 --- a/airsync-mac/Model/DeviceStatus.swift +++ b/airsync-mac/Model/DeviceStatus.swift @@ -9,21 +9,27 @@ import Foundation struct DeviceStatus: Codable { struct Battery: Codable { - let level: Int - let isCharging: Bool + var level: Int + var isCharging: Bool } struct Music: Codable { - let isPlaying: Bool - let title: String - let artist: String - let volume: Int - let isMuted: Bool - let albumArt: String - let likeStatus: String + var isPlaying: Bool + var title: String + var artist: String + var volume: Int + var isMuted: Bool + var albumArt: String + var likeStatus: String + /// Total track duration in seconds. -1 means not available. + var duration: Double + /// Current playback position in seconds (corrected for network transit on Mac side). + var position: Double + /// True when Android is buffering — position is frozen, Mac timer should pause. + var isBuffering: Bool } - let battery: Battery - let isPaired: Bool - let music: Music + var battery: Battery + var isPaired: Bool + var music: Music? } diff --git a/airsync-mac/Model/NowPlayingInfo.swift b/airsync-mac/Model/NowPlayingInfo.swift index a17385e3..01f82ee9 100644 --- a/airsync-mac/Model/NowPlayingInfo.swift +++ b/airsync-mac/Model/NowPlayingInfo.swift @@ -16,6 +16,8 @@ struct NowPlayingInfo { var artworkData: Data? = nil var artworkMimeType: String? = nil var bundleIdentifier: String? = nil + var timestamp: String? = nil + var playbackRate: Double? = 1.0 mutating func updateFromPayload(_ payload: [String: Any]) { if let title = payload["title"] as? String { self.title = title } @@ -24,6 +26,8 @@ struct NowPlayingInfo { if let elapsed = payload["elapsedTime"] as? Double { self.elapsedTime = elapsed } if let duration = payload["duration"] as? Double { self.duration = duration } if let playing = payload["playing"] as? Bool { self.isPlaying = playing } + if let rate = payload["playbackRate"] as? Double { self.playbackRate = rate } + if let ts = payload["timestamp"] as? String { self.timestamp = ts } if let artworkBase64 = payload["artworkData"] as? String, let data = Data(base64Encoded: artworkBase64) { self.artworkData = data diff --git a/airsync-mac/Model/SettingsTab.swift b/airsync-mac/Model/SettingsTab.swift new file mode 100644 index 00000000..d4e97a02 --- /dev/null +++ b/airsync-mac/Model/SettingsTab.swift @@ -0,0 +1,58 @@ +// +// SettingsTab.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-05-20. +// + +import SwiftUI + +enum SettingsTab: String, CaseIterable, Identifiable { + case myMac = "my_mac" + case sync = "sync" + case mirroring = "mirroring" + case quickShare = "quick_share" + case menubar = "menubar" + case appearance = "appearance" + case airsyncPlus = "airsync_plus" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .myMac: + return L("settings.myMac") + case .sync: + return L("settings.sync") + case .mirroring: + return L("settings.mirroring") + case .quickShare: + return L("settings.quickshare") + case .menubar: + return L("settings.menubar") + case .appearance: + return L("settings.appearance") + case .airsyncPlus: + return L("settings.airsyncPlus") + } + } + + var icon: String { + switch self { + case .myMac: + return DeviceTypeUtil.deviceIconName() + case .sync: + return "arrow.triangle.2.circlepath" + case .mirroring: + return "apps.iphone.badge.plus" + case .quickShare: + return "laptopcomputer.and.arrow.down" + case .menubar: + return "menubar.arrow.up.rectangle" + case .appearance: + return "paintbrush" + case .airsyncPlus: + return "plus.diamond.fill" + } + } +} diff --git a/airsync-mac/Screens/HomeScreen/AppContentView.swift b/airsync-mac/Screens/HomeScreen/AppContentView.swift index 90dbd6f2..ea73420b 100644 --- a/airsync-mac/Screens/HomeScreen/AppContentView.swift +++ b/airsync-mac/Screens/HomeScreen/AppContentView.swift @@ -20,7 +20,7 @@ struct AppContentView: View { if appState.device == nil { ScannerView() .tabItem { - Image(systemName: "qrcode") + Image(systemName: "iphone.motion") // Label("Scan", systemImage: "qrcode") } .tag(TabIdentifier.qr) @@ -119,7 +119,7 @@ struct AppContentView: View { } } .tabViewStyle(.automatic) - .frame(minWidth: 550) + .frame(minWidth: 550, minHeight: 510) .onAppear { // Ensure the correct tab is selected when the view appears if appState.device == nil { diff --git a/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift b/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift index 9f26bd1d..9d58486a 100644 --- a/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift +++ b/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift @@ -39,7 +39,11 @@ struct AppGridView: View { .padding(12) } } - .searchable(text: $searchText, placement: .toolbar, prompt: "Search Apps") + .searchable( + text: $searchText, + placement: .toolbar, + prompt: "Search Apps" + ) .padding(0) } } diff --git a/airsync-mac/Screens/HomeScreen/Components/CallWindowView.swift b/airsync-mac/Screens/HomeScreen/Components/CallWindowView.swift index f5e372cc..8edeacae 100644 --- a/airsync-mac/Screens/HomeScreen/Components/CallWindowView.swift +++ b/airsync-mac/Screens/HomeScreen/Components/CallWindowView.swift @@ -114,8 +114,8 @@ struct CallWindowView: View { } } - // Action buttons (only show when ringing/offhook AND ADB is connected) - if showActionButtons && appState.adbConnected { + // Action buttons (only show when ringing/offhook AND companion device is active AND is an AirSync+ subscriber) + if showActionButtons && appState.device != nil && appState.isPlus { HStack(spacing: 16) { if callEvent.direction == .incoming { diff --git a/airsync-mac/Screens/HomeScreen/HomeView.swift b/airsync-mac/Screens/HomeScreen/HomeView.swift index 13e474ab..b550cf5f 100644 --- a/airsync-mac/Screens/HomeScreen/HomeView.swift +++ b/airsync-mac/Screens/HomeScreen/HomeView.swift @@ -25,8 +25,16 @@ struct HomeView: View { var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { ZStack { - SidebarView() - .transition(.opacity.combined(with: .scale)) + if appState.selectedTab == .settings { + SettingsSidebarView() + .transition(.opacity.combined(with: .scale)) + } else if appState.device == nil { + QRScannerSidebarView() + .transition(.opacity.combined(with: .scale)) + } else { + SidebarView() + .transition(.opacity.combined(with: .scale)) + } } .frame(minWidth: 270) } detail: { @@ -65,7 +73,7 @@ struct HomeView: View { private func updateSidebarVisibility() { withAnimation(.easeInOut(duration: 0.3)) { - columnVisibility = appState.device != nil ? .all : .detailOnly + columnVisibility = .all } } } diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index 32bc5357..7c1814c9 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -9,6 +9,7 @@ import SwiftUI struct ConnectionStatusPill: View { @ObservedObject var appState = AppState.shared + @ObservedObject var bleManager = BLECentralManager.shared @State private var showingPopover = false @State private var isHovered = false @@ -18,9 +19,11 @@ struct ConnectionStatusPill: View { }) { HStack(spacing: 8) { // Network Connection Icon - Image(systemName: appState.isConnectedOverLocalNetwork ? "wifi" : "globe") - .contentTransition(.symbolEffect(.replace)) - .help(appState.isConnectedOverLocalNetwork ? "Local WiFi" : "Extended Connection (Tailscale)") + if let ip = appState.device?.ipAddress, ip != "BLE" { + Image(systemName: appState.isConnectedOverLocalNetwork ? "wifi" : "globe") + .contentTransition(.symbolEffect(.replace)) + .help(appState.isConnectedOverLocalNetwork ? "Local WiFi" : "Extended Connection (Tailscale)") + } if appState.isPlus { if appState.adbConnecting { @@ -57,6 +60,15 @@ struct ConnectionStatusPill: View { )) } } + + if bleManager.isAuthenticated { + Image("logo.bluetooth") + .help("BLE Connected") + .transition(.asymmetric( + insertion: .scale.combined(with: .opacity), + removal: .opacity + )) + } } .padding(.horizontal, 10) .padding(.vertical, 6) @@ -67,6 +79,7 @@ struct ConnectionStatusPill: View { .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.adbConnectionMode) .animation(.spring(response: 0.4, dampingFraction: 0.8), value: appState.isConnectedOverLocalNetwork) .animation(.spring(response: 0.4, dampingFraction: 0.8), value: QuickShareManager.shared.isRunning) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: bleManager.connectionStatus) } .buttonStyle(.plain) .onHover { hovering in @@ -101,8 +114,18 @@ struct ConnectionStatusPill: View { struct ConnectionPillPopover: View { @ObservedObject var appState = AppState.shared @ObservedObject var quickShareManager = QuickShareManager.shared + @ObservedObject var bleManager = BLECentralManager.shared @State private var currentIPAddress: String = "N/A" + var bleStatusText: String { + switch bleManager.connectionStatus { + case .scanning: return "Scanning..." + case .connected: return "Authenticating..." + case .authenticated: return "Connected" + case .disconnected: return "Disconnected" + } + } + var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Connection") @@ -119,8 +142,8 @@ struct ConnectionPillPopover: View { ConnectionInfoText( label: "IP Address", icon: "wifi", - text: currentIPAddress, - activeIp: appState.activeMacIp + text: appState.device?.ipAddress == "BLE" ? "BLE only" : currentIPAddress, + activeIp: appState.device?.ipAddress == "BLE" ? nil : appState.activeMacIp ) if appState.isPlus && appState.adbConnected { @@ -137,6 +160,14 @@ struct ConnectionPillPopover: View { Toggle("", isOn: $quickShareManager.isEnabled) .toggleStyle(.switch) } + + if appState.isBLEEnabled { + ConnectionInfoText( + label: "Bluetooth LE", + icon: "logo.bluetooth", + text: bleStatusText + ) + } } .padding(.bottom, 4) @@ -195,8 +226,28 @@ struct ConnectionPillPopover: View { .focusable(false) } } else { - Text("No device connected") - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 8) { + + HStack { + Label("Bluetooth LE Discovery", image: "logo.bluetooth") + .font(.system(size: 12)) + Spacer() + Toggle("", isOn: $appState.isBLEEnabled) + .toggleStyle(.switch) + .controlSize(.small) + } + + HStack { + Label("Auto-connect", systemImage: "arrow.triangle.2.circlepath") + .font(.system(size: 12)) + Spacer() + Toggle("", isOn: $appState.isBLEAutoConnectEnabled) + .toggleStyle(.switch) + .controlSize(.small) + .disabled(!appState.isBLEEnabled) + } + } + .frame(width: 240) } } .padding() diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift index 0e4cbac1..56048a0e 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift @@ -19,13 +19,12 @@ struct DeviceStatusView: View { var body: some View { VStack { - if let music = appState.status?.music, - let title = appState.status?.music.title.trimmingCharacters(in: .whitespacesAndNewlines), - !title.isEmpty, - !appState.isMusicCardHidden, showMediaToggle { - - MediaPlayerView(music: music) - .transition(.opacity.combined(with: .scale)) + if let music = appState.status?.music { + let title = music.title.trimmingCharacters(in: .whitespacesAndNewlines) + if !title.isEmpty && !appState.isMusicCardHidden && showMediaToggle { + MediaPlayerView(music: music) + .transition(.opacity.combined(with: .move(edge: .top))) + } } HStack(spacing: 8) { @@ -42,8 +41,8 @@ struct DeviceStatusView: View { } .padding(.leading, 4) - let volume = appState.status?.music.volume ?? 100 - let isMuted = appState.status?.music.isMuted ?? false + let volume = appState.status?.music?.volume ?? 100 + let isMuted = appState.status?.music?.isMuted ?? false GlassButtonView( label: "Music Player", @@ -52,7 +51,7 @@ struct DeviceStatusView: View { primary: false, action: { if AppState.shared.isPlus && AppState.shared.licenseCheck { - if let currentVolume = appState.status?.music.volume { + if let currentVolume = appState.status?.music?.volume { tempVolume = Double(currentVolume) } showingVolumePopover.toggle() @@ -89,23 +88,24 @@ struct DeviceStatusView: View { .popover(isPresented: $showingPlusPopover, arrowEdge: .bottom) { PlusFeaturePopover(message: "Control volume with AirSync+") } - - if let title = appState.status?.music.title.trimmingCharacters(in: .whitespacesAndNewlines), - !title.isEmpty && showMediaToggle { - - GlassButtonView( - label: "Music Player", - systemImage: appState.status?.music.isPlaying == true ? "play.rectangle" : "music.note", - iconOnly: true, - primary: false, - action: { - withAnimation(.easeInOut(duration: 0.28)) { - appState.isMusicCardHidden.toggle() + + if let music = appState.status?.music { + let title = music.title.trimmingCharacters(in: .whitespacesAndNewlines) + if !title.isEmpty && showMediaToggle { + GlassButtonView( + label: "Music Player", + systemImage: music.isPlaying == true ? "play.rectangle" : "music.note", + iconOnly: true, + primary: false, + action: { + withAnimation(.easeInOut(duration: 0.28)) { + appState.isMusicCardHidden.toggle() + } } - } - ) - .help(appState.isMusicCardHidden ? "Show player" : "Hide player") - .transition(.opacity.combined(with: .scale)) + ) + .help(appState.isMusicCardHidden ? "Show player" : "Hide player") + .transition(.opacity.combined(with: .scale)) + } } } .padding(.bottom, appState.isMusicCardHidden ? 0 : 8) @@ -115,17 +115,17 @@ struct DeviceStatusView: View { .applyGlassViewIfAvailable() .animation( .easeInOut(duration: 0.25), - value: "\(appState.status?.battery.level ?? 0)-\(appState.status?.music.volume ?? 0)" + value: "\(appState.status?.battery.level ?? 0)-\(appState.status?.music?.volume ?? 0)" ) .onAppear { // Ensure media card is collapsed at startup if no media is present checkAndCollapseIfNoMedia() } - .onChange(of: appState.status?.music.title) { _, newTitle in + .onChange(of: appState.status?.music?.title) { _, newTitle in // Auto-collapse music card when there's no media playing checkAndCollapseIfNoMedia() } - .onChange(of: appState.status?.music.artist) { _, newArtist in + .onChange(of: appState.status?.music?.artist) { _, newArtist in // Also check when artist changes (could become empty) checkAndCollapseIfNoMedia() } diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift index f3000b0a..e45e2d94 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift @@ -6,15 +6,67 @@ // import SwiftUI +import Combine + +// MARK: - Seekbar sub-view + +private struct MediaSeekbarView: View { + let music: DeviceStatus.Music + @ObservedObject var appState = AppState.shared + + var body: some View { + VStack(spacing: 2) { + // Slider + Slider( + value: $appState.mediaPosition, + in: 0...max(music.duration, 1), + onEditingChanged: { editing in + appState.isDraggingMedia = editing + if !editing { + appState.handleMediaSeek(to: appState.mediaPosition) + } + } + ) + .accentColor(.primary) + .padding(.horizontal, 2) + + // Time labels + HStack { + Text(formatTime(appState.mediaPosition)) + Spacer() + Text(formatTime(music.duration)) + } + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + + private func formatTime(_ seconds: Double) -> String { + guard seconds >= 0 else { return "--:--" } + let s = Int(seconds) + let m = s / 60 + let h = m / 60 + if h > 0 { + return String(format: "%d:%02d:%02d", h, m % 60, s % 60) + } + return String(format: "%d:%02d", m, s % 60) + } +} + +// MARK: - Main MediaPlayerView struct MediaPlayerView: View { var music: DeviceStatus.Music @State private var showingPlusPopover = false - var body: some View { - ZStack{ + private var hasSeekbar: Bool { + music.duration > 0 + } - VStack{ + var body: some View { + ZStack { + VStack(spacing: 6) { + // Title + artist HStack(spacing: 4) { Image(systemName: "music.note.list") EllipsesTextView( @@ -26,82 +78,70 @@ struct MediaPlayerView: View { EllipsesTextView( text: music.artist, - font: .footnote, + font: .footnote ) - - Group { if AppState.shared.isPlus && AppState.shared.licenseCheck { - HStack{ - if (AppState.shared.status?.music.likeStatus == "liked" || AppState.shared.status?.music.likeStatus == "not_liked") { - GlassButtonView( - label: "", - systemImage: { - if let like = AppState.shared.status?.music.likeStatus { - switch like { - case "liked": return "heart.fill" + VStack(spacing: 6) { + // Seekbar (shown only when duration is known and toggle is enabled) + if hasSeekbar { + MediaSeekbarView(music: music) + .padding(.top, 2) + .transition(.opacity.combined(with: .scale(scale: 0.97))) + } + + // Media control buttons + HStack { + if music.likeStatus == "liked" || music.likeStatus == "not_liked" { + GlassButtonView( + label: "", + systemImage: { + switch music.likeStatus { + case "liked": return "heart.fill" case "not_liked": return "heart" - default: return "heart.slash" + default: return "heart.slash" + } + }(), + iconOnly: true, + action: { + if music.likeStatus == "liked" { + WebSocketServer.shared.unlike() + } else if music.likeStatus == "not_liked" { + WebSocketServer.shared.like() + } else { + WebSocketServer.shared.toggleLike() } } - return "heart.slash" - }(), - iconOnly: true, - action: { - guard let like = AppState.shared.status?.music.likeStatus else { return } - if like == "liked" { - WebSocketServer.shared.unlike() - } else if like == "not_liked" { - WebSocketServer.shared.like() - } else { - WebSocketServer.shared.toggleLike() - } - } - ) - .help("Like / Unlike") - } else { + ) + .help("Like / Unlike") + } else { + GlassButtonView( + label: "", + systemImage: "backward.end", + iconOnly: true, + action: { WebSocketServer.shared.skipPrevious() } + ) + .keyboardShortcut(.leftArrow, modifiers: .control) + } - GlassButtonView( - label: "", - systemImage: "backward.end", - iconOnly: true, - action: { - WebSocketServer.shared.skipPrevious() - } - ) - .keyboardShortcut( - .leftArrow, - modifiers: .control - ) - } - GlassButtonView( label: "", systemImage: music.isPlaying ? "pause.fill" : "play.fill", iconOnly: true, primary: true, - action: { - WebSocketServer.shared.togglePlayPause() - } - ) - .keyboardShortcut( - .space, - modifiers: .control + action: { WebSocketServer.shared.togglePlayPause() } ) + .keyboardShortcut(.space, modifiers: .control) GlassButtonView( label: "", systemImage: "forward.end", iconOnly: true, - action: { - WebSocketServer.shared.skipNext() - } - ) - .keyboardShortcut( - .rightArrow, - modifiers: .control + action: { WebSocketServer.shared.skipNext() } ) + .keyboardShortcut(.rightArrow, modifiers: .control) + } } } } @@ -117,7 +157,6 @@ struct MediaPlayerView: View { } } - #Preview { MediaPlayerView(music: MockData.sampleMusic) } diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/PhoneView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/PhoneView.swift index 83a62e49..07e3225b 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/PhoneView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/PhoneView.swift @@ -83,16 +83,16 @@ struct PhoneView: View { } ) .onAppear { updateImage() } - .onChange(of: appState.status?.music.isPlaying) { updateImage() } - .onChange(of: appState.status?.music.albumArt) { updateImage() } + .onChange(of: appState.status?.music?.isPlaying) { updateImage() } + .onChange(of: appState.status?.music?.albumArt) { updateImage() } .onChange(of: AppState.shared.currentDeviceWallpaperBase64) { updateImage() } .frame(maxWidth: .infinity, maxHeight: .infinity) } } private func updateImage() { - let base64 = (appState.status?.music.isPlaying ?? false) - ? appState.status?.music.albumArt + let base64 = (appState.status?.music?.isPlaying ?? false) + ? appState.status?.music?.albumArt : AppState.shared.currentDeviceWallpaperBase64 guard let base64 = base64, diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift index b95c05b1..8fff06cd 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import AppKit struct ScreenView: View { @ObservedObject var appState = AppState.shared @@ -35,12 +36,12 @@ struct ScreenView: View { if appState.device != nil { - HStack(spacing: 10){ GlassButtonView( label: "Send", - systemImage: "paperplane.fill", - iconOnly: appState.adbConnected, + systemImage: "square.and.arrow.up", + iconOnly: true, + fixedIconSize: 16, action: { let panel = NSOpenPanel() panel.allowsMultipleSelection = true @@ -58,67 +59,54 @@ struct ScreenView: View { .keyboardShortcut( "f", modifiers: .command - ) + ) - GlassButtonView( - label: "Browse", - systemImage: "folder", - iconOnly: true, - action: { - if appState.isPlus && appState.licenseCheck { - appState.openFileBrowser() - } else { - showingPlusPopover = true - } - } - ) - .transition(.identity) - .keyboardShortcut( - "b", - modifiers: .command - ) - .popover(isPresented: $showingPlusPopover, arrowEdge: .bottom) { - PlusFeaturePopover(message: "Browse files with AirSync+") - } - - - if appState.adbConnected{ + if appState.device?.ipAddress != "BLE" { GlassButtonView( - label: "Mirror", - systemImage: "apps.iphone", + label: "Browse", + systemImage: "folder", + iconOnly: true, + fixedIconSize: 16, action: { - ADBConnector - .startScrcpy( - ip: appState.device?.ipAddress ?? "", - port: appState.adbPort, - deviceName: appState.device?.name ?? "My Phone" - ) + if appState.isPlus && appState.licenseCheck { + appState.openFileBrowser() + } else { + showingPlusPopover = true + } } ) .transition(.identity) .keyboardShortcut( - "p", + "b", modifiers: .command ) - .contextMenu { - Button("Android Mirror") { - appState.isNativeMirroring = true - } - - Button("Desktop Mode") { - ADBConnector.startScrcpy( - ip: appState.device?.ipAddress ?? "", - port: appState.adbPort, - deviceName: appState.device?.name ?? "My Phone", - desktop: true - ) - } + .popover(isPresented: $showingPlusPopover, arrowEdge: .bottom) { + PlusFeaturePopover(message: "Browse files with AirSync+") } - .keyboardShortcut( - "p", - modifiers: [.command, .shift] - ) } + + GlassButtonView( + label: "Mute", + systemImage: appState.silenceAllNotifications ? "bell.slash.fill" : "bell.badge", + iconOnly: true, + fixedIconSize: 16, + action: { + appState.silenceAllNotifications.toggle() + } + ) + .transition(.identity) + + GlassButtonView( + label: "Clipboard", + systemImage: "clipboard", + iconOnly: true, + fixedIconSize: 16, + action: { + sendClipboard() + } + ) + .transition(.identity) + } } if (appState.status != nil){ @@ -138,6 +126,40 @@ struct ScreenView: View { value: appState.isMusicCardHidden ) } + + private func sendClipboard() { + let pasteboard = NSPasteboard.general + if let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], let firstUrl = urls.first { + if appState.device?.ipAddress != "BLE" { + DispatchQueue.global(qos: .userInitiated).async { + WebSocketServer.shared.sendFile(url: firstUrl, isClipboard: true) + } + } else { + print("[ScreenView] Cannot send files over BLE") + } + } else if let image = NSImage(pasteboard: pasteboard) { + if appState.device?.ipAddress != "BLE" { + let tempDir = FileManager.default.temporaryDirectory + let tempUrl = tempDir.appendingPathComponent("clipboard_image_\(Int(Date().timeIntervalSince1970)).png") + if let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData), + let pngData = bitmap.representation(using: .png, properties: [:]) { + do { + try pngData.write(to: tempUrl) + DispatchQueue.global(qos: .userInitiated).async { + WebSocketServer.shared.sendFile(url: tempUrl, isClipboard: true) + } + } catch { + print("[ScreenView] Failed to save clipboard image: \(error)") + } + } + } else { + print("[ScreenView] Cannot send images over BLE") + } + } else if let text = pasteboard.string(forType: .string) { + appState.sendClipboardToAndroid(text: text) + } + } } #Preview { diff --git a/airsync-mac/Screens/HomeScreen/SidebarView.swift b/airsync-mac/Screens/HomeScreen/SidebarView.swift index 3b9a76ce..061c8880 100644 --- a/airsync-mac/Screens/HomeScreen/SidebarView.swift +++ b/airsync-mac/Screens/HomeScreen/SidebarView.swift @@ -10,6 +10,7 @@ import SwiftUI struct SidebarView: View { @ObservedObject var appState = AppState.shared @State private var isExpandedAllSeas: Bool = false + @State private var showingPlusDesktopPopover = false var body: some View { VStack { @@ -22,9 +23,10 @@ struct SidebarView: View { Text(truncated) .font(.title3) } - .padding(8) + .padding(6) if let deviceVersion = appState.device?.version, + appState.device?.ipAddress != "BLE", isVersion(deviceVersion, lessThan: appState.minAndroidVersion) { Label("Your Android app is outdated", systemImage: "iphone.badge.exclamationmark") .padding(4) @@ -35,16 +37,88 @@ struct SidebarView: View { .opacity(appState.device != nil ? 1 : 0.5) Spacer() + } .animation(.easeInOut(duration: 0.5), value: appState.status != nil) - .frame(minWidth: 280, minHeight: 400) + .frame(minWidth: 250, minHeight: 400) .safeAreaInset(edge: .bottom) { - HStack{ - if appState.device == nil { - Label("Connect your device", systemImage: "arrow.2.circlepath.circle") + + if appState.adbConnected { + HStack(spacing: 12) { + GlassButtonView( + label: "Mirror", + systemImage: "apps.iphone", + action: { + if appState.useNativeMirroringByDefault { + appState.isNativeMirroring = true + } else { + ADBConnector.startScrcpy( + ip: appState.device?.ipAddress ?? "", + port: appState.adbPort, + deviceName: appState.device?.name ?? "My Phone" + ) + } + } + ) + .transition(.identity) + .keyboardShortcut( + "p", + modifiers: .command + ) + .contextMenu { + if appState.useNativeMirroringByDefault { + Button("scrcpy Mirror") { + ADBConnector.startScrcpy( + ip: appState.device?.ipAddress ?? "", + port: appState.adbPort, + deviceName: appState.device?.name ?? "My Phone" + ) + } + } else { + Button("Android Mirror") { + appState.isNativeMirroring = true + } + } + + Button("Desktop Mode") { + ADBConnector.startScrcpy( + ip: appState.device?.ipAddress ?? "", + port: appState.adbPort, + deviceName: appState.device?.name ?? "My Phone", + desktop: true + ) + } + } + .keyboardShortcut( + "p", + modifiers: [.command, .shift] + ) + + GlassButtonView( + label: "Desktop", + systemImage: "desktopcomputer", + action: { + if appState.isPlus && appState.licenseCheck { + ADBConnector.startScrcpy( + ip: appState.device?.ipAddress ?? "", + port: appState.adbPort, + deviceName: appState.device?.name ?? "My Phone", + desktop: true + ) + } else { + showingPlusDesktopPopover = true + } + } + ) + .transition(.identity) + .popover(isPresented: $showingPlusDesktopPopover, arrowEdge: .top) { + PlusFeaturePopover(message: "Desktop Mode is an AirSync+ feature") + } } + .padding(.top, 8) + .padding(.bottom, 12) + .transition(.opacity.combined(with: .move(edge: .bottom))) } - .padding(16) } } } diff --git a/airsync-mac/Screens/MenubarView/CallControlSegmentView.swift b/airsync-mac/Screens/MenubarView/CallControlSegmentView.swift new file mode 100644 index 00000000..5ea95369 --- /dev/null +++ b/airsync-mac/Screens/MenubarView/CallControlSegmentView.swift @@ -0,0 +1,133 @@ +// +// CallControlSegmentView.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-05-23. +// + +import SwiftUI + +struct CallControlSegmentView: View { + @ObservedObject var appState = AppState.shared + + var body: some View { + if let callEvent = appState.activeCall { + VStack(spacing: 8) { + HStack(spacing: 12) { + if let photoString = callEvent.contactPhoto, + !photoString.isEmpty, + let photoData = Data(base64Encoded: photoString, options: .ignoreUnknownCharacters) ?? Data(base64Encoded: photoString), + let image = NSImage(data: photoData) { + Image(nsImage: image) + .resizable() + .scaledToFill() + .frame(width: 48, height: 48) + .clipShape(Circle()) + } else { + Image(systemName: "person.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + .foregroundColor(.blue) + } + + VStack(alignment: .leading, spacing: 2) { + Text(callEvent.contactName) + .font(.system(size: 14, weight: .semibold)) + .lineLimit(1) + + Text(callDirectionText(callEvent) + " • " + callStateText(callEvent)) + .font(.system(size: 11)) + .foregroundColor(.secondary) + .lineLimit(1) + } + + Spacer() + } + + if appState.isPlus && appState.licenseCheck { + HStack(spacing: 16) { + if callEvent.direction == .incoming { + if isCallAccepted(callEvent) { + GlassButtonView( + label: L("menubar.call.end"), + systemImage: "phone.down.fill", + size: .large, + action: { + appState.sendCallAction(callEvent.eventId, action: "end") + } + ) + .foregroundStyle(.red) + } else { + GlassButtonView( + label: L("menubar.call.accept"), + systemImage: "phone.fill", + size: .large, + action: { + appState.sendCallAction(callEvent.eventId, action: "accept") + } + ) + .foregroundStyle(.green) + + GlassButtonView( + label: L("menubar.call.decline"), + systemImage: "phone.down.fill", + size: .large, + action: { + appState.sendCallAction(callEvent.eventId, action: "decline") + } + ) + .foregroundStyle(.red) + } + } else if callEvent.direction == .outgoing { + GlassButtonView( + label: L("menubar.call.end"), + systemImage: "phone.down.fill", + size: .large, + action: { + appState.sendCallAction(callEvent.eventId, action: "end") + } + ) + .foregroundStyle(.red) + } + } + .padding(.top, 4) + } + } + .padding(12) + .segmentStyle() + } + } + + private func callDirectionText(_ callEvent: CallEvent) -> String { + switch callEvent.direction { + case .incoming: + return L("menubar.call.incomingCall") + case .outgoing: + return L("menubar.call.outgoingCall") + } + } + + private func callStateText(_ callEvent: CallEvent) -> String { + switch callEvent.state { + case .ringing: + return L("menubar.call.ringing") + case .offhook: + return callEvent.direction == .incoming ? L("menubar.call.accepted") : L("menubar.call.ringing") + case .accepted: + return L("menubar.call.accepted") + case .rejected: + return "Rejected" + case .ended: + return "Ended" + case .missed: + return "Missed" + case .idle: + return "Idle" + } + } + + private func isCallAccepted(_ callEvent: CallEvent) -> Bool { + callEvent.state == .offhook && callEvent.direction == .incoming + } +} diff --git a/airsync-mac/Screens/MenubarView/MenubarDeviceDiscoveryView.swift b/airsync-mac/Screens/MenubarView/MenubarDeviceDiscoveryView.swift index 4d559269..8552e5ca 100644 --- a/airsync-mac/Screens/MenubarView/MenubarDeviceDiscoveryView.swift +++ b/airsync-mac/Screens/MenubarView/MenubarDeviceDiscoveryView.swift @@ -8,12 +8,48 @@ import SwiftUI struct MenubarDeviceDiscoveryView: View { + @ObservedObject private var appState = AppState.shared @ObservedObject private var udpDiscovery = UDPDiscoveryManager.shared @ObservedObject private var quickConnectManager = QuickConnectManager.shared + @ObservedObject private var bleManager = BLECentralManager.shared + + private var allDiscoveredDevices: [DiscoveredDevice] { + var mergedDevices: [DiscoveredDevice] = [] + + // Start with UDP (Wi-Fi/Network) discovered devices + for udpDevice in udpDiscovery.discoveredDevices { + mergedDevices.append(udpDevice) + } + + // If BLE is enabled, merge or append BLE devices + if appState.isBLEEnabled { + let bleDevices = bleManager.discoveredBLEDevices + for bleDevice in bleDevices { + if let index = mergedDevices.firstIndex(where: { ScannerView.namesAreSimilar($0.name, bleDevice.name) }) { + var matchedDevice = mergedDevices[index] + matchedDevice.ips.insert("Bluetooth LE") + mergedDevices[index] = matchedDevice + } else { + let cleanedName = ScannerView.cleanDeviceName(bleDevice.name) + let cleanedBLEDevice = DiscoveredDevice( + deviceId: bleDevice.deviceId, + name: cleanedName, + ips: bleDevice.ips, + port: bleDevice.port, + type: bleDevice.type, + lastSeen: bleDevice.lastSeen + ) + mergedDevices.append(cleanedBLEDevice) + } + } + } + + return mergedDevices + } var body: some View { VStack(alignment: .leading, spacing: 8) { - let devices = udpDiscovery.discoveredDevices + let devices = allDiscoveredDevices if !devices.isEmpty { Text("Nearby Devices") .font(.system(size: 11, weight: .semibold)) @@ -24,14 +60,16 @@ struct MenubarDeviceDiscoveryView: View { HStack(spacing: 8) { let lastConnected = quickConnectManager.getLastConnectedDevice() ForEach(devices) { device in - DeviceCard( + CompactDeviceCard( device: device, - isLastConnected: lastConnected?.name == device.name && (lastConnected != nil && device.ips.contains(lastConnected!.ipAddress)), - isCompact: true, + isLastConnected: lastConnected != nil && ScannerView.namesAreSimilar(lastConnected!.name, device.name), connectAction: { - quickConnectManager.connect(to: device) - }, - namespace: nil + if device.type == "ble" { + bleManager.connectManually(toUuid: device.deviceId) + } else { + quickConnectManager.connect(to: device) + } + } ) } } @@ -43,6 +81,81 @@ struct MenubarDeviceDiscoveryView: View { } } +struct CompactDeviceCard: View { + let device: DiscoveredDevice + let isLastConnected: Bool + let connectAction: () -> Void + + @ObservedObject private var quickConnectManager = QuickConnectManager.shared + @ObservedObject private var bleManager = BLECentralManager.shared + + private var isLoading: Bool { + if device.type == "ble" { + return bleManager.connectingDeviceUUID == device.deviceId + } + return quickConnectManager.connectingDeviceID == device.id + } + + var body: some View { + VStack(spacing: 6) { + Image(systemName: "iphone") + .font(.system(size: 24)) + .foregroundColor(.secondary) + .padding(.top, 8) + + Text(device.name) + .font(.system(size: 11, weight: .bold)) + .lineLimit(1) + .multilineTextAlignment(.center) + + HStack(spacing: 4) { + if device.type == "ble" { + Image("logo.bluetooth") + .foregroundColor(.accentColor) + } else { + if device.ips.contains("Bluetooth LE") { + Image("logo.bluetooth") + } + if device.ips.contains(where: { $0 != "Bluetooth LE" && !$0.hasPrefix("100.") }) { + Image(systemName: "wifi") + } + if device.ips.contains(where: { $0 != "Bluetooth LE" && $0.hasPrefix("100.") }) { + Image(systemName: "globe") + } + } + + if isLastConnected { + Text("Last connected") + .font(.system(size: 8, weight: .semibold)) + .foregroundColor(.accentColor) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Color.accentColor.opacity(0.15), in: .capsule) + } + } + .font(.system(size: 8)) + .foregroundColor(.secondary) + + + + Spacer() + + GlassButtonView( + label: "Connect", + systemImage: "bolt.circle.fill", + primary: device.isActive, + isLoading: isLoading, + action: connectAction + ) + .frame(maxWidth: .infinity) + + } + .padding(8) + .frame(width: 125, height: 135) + .glassBoxIfAvailable(radius: 12) + } +} + #Preview { MenubarDeviceDiscoveryView() .frame(width: 320) diff --git a/airsync-mac/Screens/MenubarView/MenubarSegments.swift b/airsync-mac/Screens/MenubarView/MenubarSegments.swift index 23310beb..c1976061 100644 --- a/airsync-mac/Screens/MenubarView/MenubarSegments.swift +++ b/airsync-mac/Screens/MenubarView/MenubarSegments.swift @@ -73,6 +73,16 @@ struct TopSegmentView: View { openQuickShare() } ) + + GlassButtonView( + label: L("menu.browseFiles"), + systemImage: "folder", + iconOnly: true, + circleSize: toolButtonSize, + action: { + WebDAVManager.shared.openInFinder() + } + ) if appState.adbConnected { GlassButtonView( @@ -81,16 +91,30 @@ struct TopSegmentView: View { iconOnly: true, circleSize: toolButtonSize, action: { - ADBConnector.startScrcpy( - ip: appState.device?.ipAddress ?? "", - port: appState.adbPort, - deviceName: appState.device?.name ?? "My Phone" - ) + if appState.useNativeMirroringByDefault { + appState.isNativeMirroring = true + } else { + ADBConnector.startScrcpy( + ip: appState.device?.ipAddress ?? "", + port: appState.adbPort, + deviceName: appState.device?.name ?? "My Phone" + ) + } } ) .contextMenu { - Button("Android Mirror") { - appState.isNativeMirroring = true + if appState.useNativeMirroringByDefault { + Button("scrcpy Mirror") { + ADBConnector.startScrcpy( + ip: appState.device?.ipAddress ?? "", + port: appState.adbPort, + deviceName: appState.device?.name ?? "My Phone" + ) + } + } else { + Button("Android Mirror") { + appState.isNativeMirroring = true + } } Button("Desktop Mode") { @@ -169,7 +193,7 @@ struct MediaSegmentView: View { if let status = appState.status { DeviceStatusView(showMediaToggle: true) .background { - let artwork = status.music.albumArt + let artwork = status.music?.albumArt ?? "" if !appState.isMusicCardHidden, !artwork.isEmpty, let data = Data(base64Encoded: artwork), @@ -190,10 +214,14 @@ struct MediaSegmentView: View { struct DiscoverySegmentView: View { @ObservedObject var appState = AppState.shared - @StateObject private var udpDiscovery = UDPDiscoveryManager.shared + @ObservedObject private var udpDiscovery = UDPDiscoveryManager.shared + @ObservedObject private var bleManager = BLECentralManager.shared var body: some View { - if appState.device == nil && !udpDiscovery.discoveredDevices.isEmpty { + let hasUdp = !udpDiscovery.discoveredDevices.isEmpty + let hasBle = appState.isBLEEnabled && !bleManager.discoveredBLEDevices.isEmpty + + if appState.device == nil && (hasUdp || hasBle) { MenubarDeviceDiscoveryView() .padding(10) .segmentStyle() @@ -220,3 +248,6 @@ struct NotificationsSegmentView: View { } } } + + + diff --git a/airsync-mac/Screens/MenubarView/MenubarView.swift b/airsync-mac/Screens/MenubarView/MenubarView.swift index f0763d06..45377892 100644 --- a/airsync-mac/Screens/MenubarView/MenubarView.swift +++ b/airsync-mac/Screens/MenubarView/MenubarView.swift @@ -58,17 +58,19 @@ struct MenubarView: View { ) .staggeredEntrance(index: 0, isVisible: appState.isMenubarWindowOpen) - DiscoverySegmentView() + CallControlSegmentView() .staggeredEntrance(index: 1, isVisible: appState.isMenubarWindowOpen) - MediaSegmentView() + DiscoverySegmentView() .staggeredEntrance(index: 2, isVisible: appState.isMenubarWindowOpen) - NotificationsSegmentView() + MediaSegmentView() .staggeredEntrance(index: 3, isVisible: appState.isMenubarWindowOpen) + + NotificationsSegmentView() + .staggeredEntrance(index: 4, isVisible: appState.isMenubarWindowOpen) } .padding(.horizontal, 24) - .padding(.top, 24) .padding(.bottom, 24) .frame(width: minWidthTabs + 48) .environment(\.controlActiveState, .active) diff --git a/airsync-mac/Screens/OnboardingView/PlusFeaturesView.swift b/airsync-mac/Screens/OnboardingView/PlusFeaturesView.swift index 95726193..7220cfe3 100644 --- a/airsync-mac/Screens/OnboardingView/PlusFeaturesView.swift +++ b/airsync-mac/Screens/OnboardingView/PlusFeaturesView.swift @@ -30,7 +30,8 @@ struct PlusFeaturesView: View { featureRow(icon: "music.note", title: "Media Controls", description: "Control music playback and volume directly from your Mac") featureRow(icon: "desktopcomputer", title: "Wireless Desktop Mode", description: "Use the phone in a familiar way, with full desktop controls") featureRow(icon: "phone", title: "Control calls", description: "Accept, decline, or end phone calls from your Mac (ADB)") - featureRow(icon: "folder", title: "File Browser", description: "Browse and transfer files between your Mac and Android device wirelessly") + featureRow(icon: "folder", title: "File Browser & Mounting", description: "Browse, manage, and mount your Android storage directly as a local Finder drive") + featureRow(icon: "menubar.rectangle", title: "MenuBar Customizations", description: "Customize menu bar text style, font size, battery style, and album art layout") featureRow(icon: "bell.badge", title: "Advanced Notifications", description: "Enhanced notification management and customization", soon: true) featureRow(icon: "battery.25percent", title: "Low Battery Alerts", description: "Get notified when your Android device needs charging", soon: true) featureRow(icon: "widget.small.badge.plus", title: "Widgets", description: "Synced widgets with device status and more", soon: true) diff --git a/airsync-mac/Screens/ScannerView/DeviceCard.swift b/airsync-mac/Screens/ScannerView/DeviceCard.swift index 1d2be24a..21f0842b 100644 --- a/airsync-mac/Screens/ScannerView/DeviceCard.swift +++ b/airsync-mac/Screens/ScannerView/DeviceCard.swift @@ -3,83 +3,23 @@ import SwiftUI struct DeviceCard: View { let device: DiscoveredDevice let isLastConnected: Bool - let isCompact: Bool let connectAction: () -> Void let namespace: Namespace.ID? @State private var wallpaperImage: NSImage? @ObservedObject private var quickConnectManager = QuickConnectManager.shared + @ObservedObject private var bleManager = BLECentralManager.shared private var isLoading: Bool { - quickConnectManager.connectingDeviceID == device.id + if device.type == "ble" { + return bleManager.connectingDeviceUUID == device.deviceId + } + return quickConnectManager.connectingDeviceID == device.id } var body: some View { Group { - if isCompact { - // Compact Mode - Button(action: connectAction) { - HStack(spacing: 8) { - ZStack { - if isLoading { - ProgressView() - .controlSize(.small) - .frame(width: 16, height: 16) - } - - Image(systemName: "iphone") - .font(.system(size: 16)) - .ifLet(namespace) { view, ns in - view.matchedGeometryEffect(id: "icon-\(device.id)", in: ns) - } - .opacity(isLoading ? 0 : 1) - } - - VStack(alignment: .leading, spacing: 0) { - Text(device.name) - .font(.system(size: 12, weight: .semibold)) - .lineLimit(1) - .ifLet(namespace) { view, ns in - view.matchedGeometryEffect(id: "name-\(device.id)", in: ns) - } - } - - if !device.isActive { - Image(systemName: "clock") - .foregroundColor(.secondary) - .transition(.opacity.combined(with: .scale(scale: 0.9))) - } - - HStack(spacing: 4) { - if device.ips.contains(where: { !$0.hasPrefix("100.") }) { - Image(systemName: "wifi") - .font(.system(size: 10)) - } - if device.ips.contains(where: { $0.hasPrefix("100.") }) { - Image(systemName: "globe") - .font(.system(size: 10)) - } - } - .foregroundColor(.secondary) - - if isLastConnected && device.isActive { - Image(systemName: "clock.arrow.circlepath") - .font(.caption2) - .foregroundColor(.accentColor) - .ifLet(namespace) { view, ns in - view.matchedGeometryEffect(id: "status-\(device.id)", in: ns) - } - } - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .glassBoxIfAvailable(radius: 20) - .opacity(device.isActive ? 1.0 : 0.7) - .grayscale(device.isActive ? 0 : 0.4) - } - .buttonStyle(.plain) - } else { - // Expanded Mode + VStack(spacing: 8) { Image(systemName: "iphone") .font(.system(size: 50)) @@ -98,20 +38,29 @@ struct DeviceCard: View { } HStack(spacing: 8) { - if device.ips.contains(where: { !$0.hasPrefix("100.") }) { - Image(systemName: "wifi") - } - if device.ips.contains(where: { $0.hasPrefix("100.") }) { - Image(systemName: "globe") - } - + if device.type == "ble" { + Image("logo.bluetooth") + Text("Nearby") + .font(.caption) + .foregroundColor(.secondary) + } else { + if device.ips.contains("Bluetooth LE") || device.ips.contains("Nearby") { + Image("logo.bluetooth") + } + if device.ips.contains(where: { $0 != "Bluetooth LE" && $0 != "Nearby" && !$0.hasPrefix("100.") }) { + Image(systemName: "wifi") + } + if device.ips.contains(where: { $0 != "Bluetooth LE" && $0 != "Nearby" && $0.hasPrefix("100.") }) { + Image(systemName: "globe") + } - // Show primary IP - let displayIP = device.ips.first(where: { !$0.hasPrefix("100.") }) ?? device.ips.first ?? "" - Text(displayIP) - .font(.caption) - .foregroundColor(.secondary) - .transition(.opacity) + // Show primary IP excluding Bluetooth LE and Nearby + let displayIP = device.ips.first(where: { $0 != "Bluetooth LE" && $0 != "Nearby" && !$0.hasPrefix("100.") }) ?? device.ips.first(where: { $0 != "Bluetooth LE" && $0 != "Nearby" }) ?? "" + Text(displayIP) + .font(.caption) + .foregroundColor(.secondary) + .transition(.opacity) + } } .font(.caption2) .foregroundColor(.secondary) @@ -170,7 +119,6 @@ struct DeviceCard: View { } .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) ) - } } .onAppear { loadWallpaper() diff --git a/airsync-mac/Screens/ScannerView/QRConnectionManager.swift b/airsync-mac/Screens/ScannerView/QRConnectionManager.swift new file mode 100644 index 00000000..79946a57 --- /dev/null +++ b/airsync-mac/Screens/ScannerView/QRConnectionManager.swift @@ -0,0 +1,116 @@ +// +// QRConnectionManager.swift +// airsync-mac +// +// Created by Sameera Sandakelum on 2026-05-19. +// + +import SwiftUI +import Combine +import QRCode +import LocalAuthentication + +class QRConnectionManager: ObservableObject { + static let shared = QRConnectionManager() + + @Published var qrImage: CGImage? + @Published var isUnlocked = false + @Published var hasValidIP = true + @Published var copyStatus: String? + @Published var showConfirmReset = false + + private var unlockTimer: Timer? + + func generateQRAsync() { + let ip = WebSocketServer.shared + .getLocalIPAddress( + adapterName: AppState.shared.selectedNetworkAdapterName + ) + + // Check if we have a valid IP address + guard let validIP = ip else { + DispatchQueue.main.async { + self.hasValidIP = false + self.qrImage = nil + } + return + } + + // If we have a valid IP, proceed with QR generation + DispatchQueue.main.async { + self.hasValidIP = true + self.qrImage = nil // Reset to show progress view + } + + let text = generateQRText( + ip: validIP, + port: UInt16(AppState.shared.myDevice?.port ?? Int(Defaults.serverPort)), + name: AppState.shared.myDevice?.name, + key: WebSocketServer.shared.getSymmetricKeyBase64() ?? "" + ) ?? "That doesn't look right, QR Generation failed" + + Task { + if let cgImage = await QRCodeGenerator.generateQRCode(for: text) { + DispatchQueue.main.async { + self.qrImage = cgImage + } + } + } + } + + func authenticateUser() { + let context = LAContext() + var error: NSError? + + if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { + let reason = "Authenticate to reveal connection credentials" + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, authenticationError in + DispatchQueue.main.async { + if success { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + self.isUnlocked = true + } + + self.unlockTimer?.invalidate() + self.unlockTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: false) { [weak self] _ in + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + self?.isUnlocked = false + } + } + } + } + } + } else { + // Fallback if no auth policy is available + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + self.isUnlocked = true + } + self.unlockTimer?.invalidate() + self.unlockTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: false) { [weak self] _ in + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + self?.isUnlocked = false + } + } + } + } + + func cleanUpTimer() { + unlockTimer?.invalidate() + unlockTimer = nil + } + + func copyToClipboard(_ text: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + + withAnimation { + copyStatus = "Copied! Keep it safe" + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { + withAnimation { + self.copyStatus = nil + } + } + } +} diff --git a/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift b/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift new file mode 100644 index 00000000..569c5da4 --- /dev/null +++ b/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift @@ -0,0 +1,172 @@ +// +// QRScannerSidebarView.swift +// airsync-mac +// +// Created by Sameera Sandakelum on 2026-05-19. +// + +import SwiftUI +import QRCode +import LocalAuthentication + +struct QRScannerSidebarView: View { + @ObservedObject var appState = AppState.shared + @ObservedObject var qrManager = QRConnectionManager.shared + + private func statusInfo(for status: WebSocketStatus) -> (text: String, icon: String, color: Color) { + switch status { + case .stopped: + return ("Stopped", "xmark.circle", .gray) + case .starting: + return ("Starting...", "clock", .orange) + case .started: + return ("Ready", "checkmark.circle", .green) + case .failed(let error): + return ("Failed: \(error)", "exclamationmark.triangle", .red) + } + } + + @State private var showingSettingsPopover = false + + var body: some View { + let info = statusInfo(for: appState.webSocketStatus) + + VStack(spacing: 16) { + + Text("Scan to connect") + .font(.title3) + .fontWeight(.bold) + .padding(.horizontal, 16) + .padding(.top, 16) + + Spacer() + + + if !qrManager.hasValidIP { + VStack { + Image(systemName: "wifi.slash") + .font(.system(size: 30)) + .foregroundColor(.gray) + .padding() + + Text("No local IP found") + .font(.title3) + .foregroundColor(.secondary) + } + .padding() + .glassBoxIfAvailable(radius: 24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + if !qrManager.isUnlocked { + // Locked UI: 1:1 card with glass background + VStack(spacing: 16) { + Spacer() + Image(systemName: "lock.shield.fill") + .font(.system(size: 36)) + .foregroundColor(.accentColor) + Text("Click to Reveal") + .font(.headline) + .foregroundColor(.primary) + Spacer() + } + .frame(width: 240, height: 240) + .glassBoxIfAvailable(radius: 24) + .contentShape(Rectangle()) + .onTapGesture { + qrManager.authenticateUser() + } + } else { + // Unlocked + if let qrImage = qrManager.qrImage { + VStack(spacing: 12) { + Image(decorative: qrImage, scale: 1.0) + .resizable() + .interpolation(.none) + .frame(width: 180, height: 180) + .accessibilityLabel("QR Code") + .shadow(radius: 20) + .padding() + .background(.black.opacity(0.6), in: .rect(cornerRadius: 30)) + + if let key = WebSocketServer.shared.getSymmetricKeyBase64(), !key.isEmpty { + VStack(spacing: 8) { + HStack { + GlassButtonView( + label: "Copy Key", + systemImage: "key", + action: { + qrManager.copyToClipboard(key) + } + ) + + GlassButtonView( + label: "Re-generate key", + systemImage: "repeat.badge.xmark", + iconOnly: true, + action: { + qrManager.showConfirmReset = true + } + ) + } + .confirmationDialog( + "Are you sure you want to reset the key? You will have to re-auth all the devices.", + isPresented: $qrManager.showConfirmReset + ) { + Button("Reset key", role: .destructive) { + WebSocketServer.shared.resetSymmetricKey() + qrManager.generateQRAsync() + } + Button("Cancel", role: .cancel) { } + } + + if let status = qrManager.copyStatus { + Text(status) + .font(.caption) + .foregroundColor(.green) + .transition(.opacity) + } + } + } + } + .frame(width: 240) + } else { + ProgressView("Generating QR…") + .frame(width: 240, height: 240) + } + } + } + + Spacer() + + HStack { + GlassButtonView( + label: info.text, + systemImage: info.icon, + action: {} + ) + .foregroundStyle(info.color) + .focusable(false) + + + GlassButtonView( + label: "", + systemImage: "gearshape", + iconOnly: true, + action: {showingSettingsPopover.toggle()} + ) + .popover(isPresented: $showingSettingsPopover, arrowEdge: .top) { + ConnectionPillPopover() + } + .focusable(false) + } + .padding(.bottom, 16) + } + .padding(.horizontal, 8) + .onAppear { + qrManager.generateQRAsync() + } + .onDisappear { + qrManager.cleanUpTimer() + } + } +} diff --git a/airsync-mac/Screens/ScannerView/ScannerView.swift b/airsync-mac/Screens/ScannerView/ScannerView.swift index c93b8eb9..79d0eacb 100644 --- a/airsync-mac/Screens/ScannerView/ScannerView.swift +++ b/airsync-mac/Screens/ScannerView/ScannerView.swift @@ -6,299 +6,183 @@ // import SwiftUI -import QRCode -internal import SwiftImageReadWrite -import CryptoKit struct ScannerView: View { @ObservedObject var appState = AppState.shared @ObservedObject private var quickConnectManager = QuickConnectManager.shared @ObservedObject private var udpDiscovery = UDPDiscoveryManager.shared - @State private var qrImage: CGImage? - @State private var showQR = true - @State private var copyStatus: String? - @State private var hasValidIP: Bool = true - @State private var showConfirmReset = false + @ObservedObject private var bleManager = BLECentralManager.shared @Namespace private var animation - private func statusInfo(for status: WebSocketStatus) -> (text: String, icon: String, color: Color) { - switch status { - case .stopped: - return ("Stopped", "xmark.circle", .gray) - case .starting: - return ("Starting...", "clock", .orange) - case .started: - return ("Ready", "checkmark.circle", .green) - case .failed(let error): - return ("Failed: \(error)", "exclamationmark.triangle", .red) - } + static func cleanDeviceName(_ name: String) -> String { + return name + .replacingOccurrences(of: "AirSync-AirSync-", with: "") + .replacingOccurrences(of: "AirSync-", with: "") + .replacingOccurrences(of: "airsync-", with: "") + .replacingOccurrences(of: "airsync", with: "") + .replacingOccurrences(of: "-", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) } - var body: some View { - - let info = statusInfo(for: appState.webSocketStatus) - - VStack { - Spacer() - - if !hasValidIP { - VStack { - Image(systemName: "wifi.slash") - .font(.system(size: 30)) - .foregroundColor(.gray) - .padding() + static func namesAreSimilar(_ name1: String, _ name2: String) -> Bool { + let clean1 = cleanDeviceName(name1).lowercased() + let clean2 = cleanDeviceName(name2).lowercased() + return clean1.contains(clean2) || clean2.contains(clean1) || clean1 == clean2 + } - Text("No local IP found") - .font(.title2) - .foregroundColor(.secondary) + private var allDiscoveredDevices: [DiscoveredDevice] { + var mergedDevices: [DiscoveredDevice] = [] + + // Start with UDP (Wi-Fi/Network) discovered devices + for udpDevice in udpDiscovery.discoveredDevices { + mergedDevices.append(udpDevice) + } + + // If BLE is enabled, merge or append BLE devices + if appState.isBLEEnabled { + let bleDevices = bleManager.discoveredBLEDevices + for bleDevice in bleDevices { + if let index = mergedDevices.firstIndex(where: { ScannerView.namesAreSimilar($0.name, bleDevice.name) }) { + var matchedDevice = mergedDevices[index] + matchedDevice.ips.insert("Bluetooth LE") + mergedDevices[index] = matchedDevice + } else { + let cleanedName = ScannerView.cleanDeviceName(bleDevice.name) + let cleanedBLEDevice = DiscoveredDevice( + deviceId: bleDevice.deviceId, + name: cleanedName, + ips: bleDevice.ips, + port: bleDevice.port, + type: bleDevice.type, + lastSeen: bleDevice.lastSeen + ) + mergedDevices.append(cleanedBLEDevice) } - .frame(width: 250, height: 250) - .padding() - } else { - - // --- QR Code & Encryption Key Section --- - if showQR { - VStack { - if let qrImage = qrImage { - HStack{ - Text("Scan to connect") - .font(.title3) - .padding() - - Label { - Text(info.text) - .foregroundColor(info.color) - } icon: { - Image(systemName: info.icon) - .foregroundColor(info.color) - } - .padding(6) - .glassBoxIfAvailable(radius: 20) + } + } + + return mergedDevices + } - } - .padding(.bottom, 4) + var body: some View { + VStack(spacing: 24) { + // Available Devices Section (UDP and BLE Discovery) + VStack(spacing: 12) { + + if allDiscoveredDevices.isEmpty { + VStack(spacing: 12) { + ProgressView() + .controlSize(.small) + Text("Looking for devices...") + .font(.subheadline) + .foregroundColor(.secondary) + } + .transition(.opacity.combined(with: .scale)) + .frame(maxWidth: .infinity, minHeight: 240) - Image(decorative: qrImage, scale: 1.0) - .resizable() - .interpolation(.none) - .frame(width: 240, height: 240) - .accessibilityLabel("QR Code") - .shadow(radius: 20) - .padding() - .background(.black.opacity(0.6), in: .rect(cornerRadius: 30)) - } else { - ProgressView("Generating QR…") - .frame(width: 100, height: 100) - } + } else { + HStack { + Spacer() + Text("Available Devices") + .font(.headline) + .foregroundColor(.secondary) + Spacer() + } + .transition(.opacity) - // Copy Key Button - if let key = WebSocketServer.shared.getSymmetricKeyBase64(), !key.isEmpty { + let devices = Array(allDiscoveredDevices.prefix(4)) + let lastConnected = quickConnectManager.getLastConnectedDevice() + + VStack { + Spacer() + if devices.count == 1 { HStack { - GlassButtonView( - label: "Copy Key", - systemImage: "key", - action: { - copyToClipboard(key) - } - ) - - GlassButtonView( - label: "Re-generate key", - systemImage: "repeat.badge.xmark", - iconOnly: true, - action: { - showConfirmReset = true - } + Spacer() + DeviceCard( + device: devices[0], + isLastConnected: lastConnected != nil && ScannerView.namesAreSimilar(lastConnected!.name, devices[0].name), + connectAction: { + if devices[0].type == "ble" { + bleManager.connectManually(toUuid: devices[0].deviceId) + } else { + quickConnectManager.connect(to: devices[0]) + } + }, + namespace: animation ) + .transition(.opacity.combined(with: .scale)) + Spacer() } - .padding(.top, 8) - .confirmationDialog( - "Are you sure you want to reset the key? You will have to re-auth all the devices.", - isPresented: $showConfirmReset - ) { - Button("Reset key", role: .destructive) { - WebSocketServer.shared.resetSymmetricKey() - generateQRAsync() + } else if devices.count == 2 { + HStack(spacing: 20) { + Spacer() + ForEach(devices) { device in + DeviceCard( + device: device, + isLastConnected: lastConnected != nil && ScannerView.namesAreSimilar(lastConnected!.name, device.name), + connectAction: { + if device.type == "ble" { + bleManager.connectManually(toUuid: device.deviceId) + } else { + quickConnectManager.connect(to: device) + } + }, + namespace: animation + ) + .transition(.opacity.combined(with: .scale)) } - Button("Cancel", role: .cancel) { } + Spacer() } - - if let status = copyStatus { - Text(status) - .font(.caption) - .foregroundColor(.green) - .transition(.opacity) - } - } - } - .transition(.move(edge: .top).combined(with: .opacity)) - .onTapGesture { - generateQRAsync() - } - } else { - Spacer() - } - - Spacer() - - // --- Nearby Devices (UDP Discovery) --- - if !udpDiscovery.discoveredDevices.isEmpty { - VStack(spacing: 12) { - // Toggle Button - HStack { - Spacer() - GlassButtonView( - label: showQR ? "Hide QR Code" : "Show QR Code", - systemImage: showQR ? "chevron.up" : "chevron.down", - action: { - withAnimation(.spring()) { - showQR.toggle() + } else { + let rows = 2 + let columns = 2 + VStack(spacing: 20) { + ForEach(0.. String { + return Localizer.shared.text(key) + } +} diff --git a/airsync-mac/Screens/Settings/MirroringSettingsView.swift b/airsync-mac/Screens/Settings/MirroringSettingsView.swift new file mode 100644 index 00000000..c5382107 --- /dev/null +++ b/airsync-mac/Screens/Settings/MirroringSettingsView.swift @@ -0,0 +1,231 @@ +// +// MirroringSettingsView.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-05-20. +// + +import SwiftUI + +struct MirroringSettingsView: View { + @ObservedObject var appState = AppState.shared + @AppStorage("scrcpyOnTop") private var scrcpyOnTop = false + @AppStorage("stayAwake") private var stayAwake = false + @AppStorage("turnScreenOff") private var turnScreenOff = false + @AppStorage("noAudio") private var noAudio = false + @AppStorage("manualPosition") private var manualPosition = false + @AppStorage("continueApp") private var continueApp = false + @AppStorage("directKeyInput") private var directKeyInput = true + @AppStorage("scrcpyDesktopDpi") private var scrcpyDesktopDpi = "" + + @State private var tempBitrate: Double = 4.00 + @State private var tempResolution: Double = 1200.00 + @State private var isDragging = false + @State private var xCoords: String = "0" + @State private var yCoords: String = "0" + + var body: some View { + Group { + if appState.isPlus { + unlockedMirroringView + } else { + lockedMirroringView + } + } + } + + private var unlockedMirroringView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + VStack{ + HStack{ + Label(L("settings.mirroring.defaultMode"), systemImage: "rectangle.on.rectangle.badge.gearshape") + Spacer() + Picker("", selection: $appState.useNativeMirroringByDefault) { + Label(L("settings.mirroring.scrcpy.title"), systemImage: "macwindow").tag(false) + Label(L("settings.mirroring.native.title"), systemImage: "apps.iphone").tag(true) + // Text(L("settings.mirroring.scrcpy.title")).tag(false) + // Text(L("settings.mirroring.native.title")).tag(true) + } + .pickerStyle(.segmented) + .controlSize(.large) + } + + Spacer(minLength: 8) + + if(appState.useNativeMirroringByDefault) { + Text(L("settings.mirroring.native.info")) + .font(.caption) + } else { + Text(L("settings.mirroring.scrcpy.info")) + .font(.caption) + } + } + .padding() + + headerSection(title: L("settings.mirroring.appMirroring"), icon: "apps.iphone.badge.plus") + + VStack(spacing: 16) { + HStack { + Label(L("settings.mirroring.enableAppMirroring"), systemImage: "apps.iphone.badge.plus") + Spacer() + Toggle("", isOn: $appState.mirroringPlus) + .toggleStyle(.switch) + } + + Divider() + + VStack(spacing: 12) { + HStack { + Text(L("settings.mirroring.videoBitrate")) + Spacer() + + Slider( + value: $tempBitrate, + in: 1...12, + step: 1, + onEditingChanged: { editing in + if !editing { + AppState.shared.scrcpyBitrate = Int(tempBitrate) + } + isDragging = editing + } + ) + .focusable(false) + .frame(maxWidth: 150) + + Text(String(format: L("settings.mirroring.bitrateFormat"), AppState.shared.scrcpyBitrate)) + .monospacedDigit() + .foregroundColor(isDragging ? .accentColor : .secondary) + .frame(width: 60, alignment: .leading) + } + + HStack { + Text(L("settings.mirroring.maxSize")) + Spacer() + + Slider( + value: $tempResolution, + in: 800...2600, + step: 200, + onEditingChanged: { editing in + if !editing { + AppState.shared.scrcpyResolution = Int(tempResolution) + } + isDragging = editing + } + ) + .focusable(false) + .frame(maxWidth: 150) + + Text("\(AppState.shared.scrcpyResolution)") + .monospacedDigit() + .foregroundColor(isDragging ? .accentColor : .secondary) + .frame(width: 60, alignment: .leading) + } + + SettingsToggleView(name: L("settings.mirroring.stayOnTop"), icon: "inset.filled.toptrailing.rectangle.portrait", isOn: $scrcpyOnTop) + + SettingsToggleView(name: L("settings.mirroring.stayAwake"), icon: "cup.and.heat.waves", isOn: $stayAwake) + + SettingsToggleView(name: L("settings.mirroring.blankDisplay"), icon: "iphone.gen3.slash", isOn: $turnScreenOff) + + SettingsToggleView(name: L("settings.mirroring.noAudio"), icon: "speaker.slash", isOn: $noAudio) + + SettingsToggleView(name: L("settings.mirroring.continueApp"), icon: "arrow.turn.up.forward.iphone", isOn: $continueApp) + + SettingsToggleView(name: L("settings.mirroring.directKeyboardInput"), icon: "keyboard.chevron.compact.down", isOn: $directKeyInput) + + HStack { + Text(L("settings.mirroring.dpi")) + Spacer() + TextField(L("settings.mirroring.dpi"), text: Binding( + get: { UserDefaults.standard.scrcpyDesktopDpi }, + set: { newValue in + UserDefaults.standard.scrcpyDesktopDpi = newValue.filter { "0123456789".contains($0) } + } + )) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 60) + } + + HStack { + Text(L("settings.mirroring.manualPosition")) + Spacer() + + TextField(L("settings.mirroring.x"), text: $xCoords) + .textFieldStyle(.roundedBorder) + .onChange(of: xCoords) { oldValue, newValue in + xCoords = newValue.filter { "0123456789".contains($0) } + } + .disabled(!manualPosition) + + TextField(L("settings.mirroring.y"), text: $yCoords) + .textFieldStyle(.roundedBorder) + .onChange(of: yCoords) { oldValue, newValue in + yCoords = newValue.filter { "0123456789".contains($0) } + } + .disabled(!manualPosition) + + GlassButtonView( + label: L("settings.mirroring.set"), + action: { + UserDefaults.standard.manualPositionCoords = [xCoords, yCoords] + } + ) + .disabled(xCoords.isEmpty || yCoords.isEmpty || !manualPosition) + + Toggle("", isOn: $manualPosition) + .toggleStyle(.switch) + } + } + } + .padding() + .glassBoxIfAvailable(radius: 18) + } + .padding() + } + .onAppear { + tempBitrate = Double(AppState.shared.scrcpyBitrate) + tempResolution = Double(AppState.shared.scrcpyResolution) + xCoords = UserDefaults.standard.manualPositionCoords[0] + yCoords = UserDefaults.standard.manualPositionCoords[1] + } + } + + private var lockedMirroringView: some View { + VStack(spacing: 20) { + Spacer() + + Image(systemName: "apps.iphone.badge.plus") + .font(.system(size: 64)) + .foregroundStyle(Color.accentColor) + .padding(.bottom, 10) + + PlusFeaturePopover(message: L("settings.mirroring.plusFeatureMessage")) + .background(Color.secondary.opacity(0.05)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.secondary.opacity(0.1), lineWidth: 1) + ) + + Spacer() + } + .padding() + } + + @ViewBuilder + private func headerSection(title: String, icon: String) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .foregroundStyle(Color.accentColor) + Text(title) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 8) + } +} + diff --git a/airsync-mac/Screens/Settings/MyMacSettingsView.swift b/airsync-mac/Screens/Settings/MyMacSettingsView.swift new file mode 100644 index 00000000..e57b97a0 --- /dev/null +++ b/airsync-mac/Screens/Settings/MyMacSettingsView.swift @@ -0,0 +1,104 @@ +import SwiftUI + +struct MyMacSettingsView: View { + @ObservedObject var appState = AppState.shared + + @State private var deviceName: String = "" + @State private var port: String = "6996" + @State private var availableAdapters: [(name: String, address: String)] = [] + @State private var currentIPAddress: String = "N/A" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // 1. Device Info + SettingsHeaderView(title: "Device Name", icon: "iphone") + VStack { + DeviceNameView(deviceName: $deviceName) + } + .padding() + .glassBoxIfAvailable(radius: 18) + + // 2. Server settings + SettingsHeaderView(title: "Server Configuration", icon: "server.rack") + VStack(spacing: 12) { + HStack { + Label("Network Adapter", systemImage: "rectangle.connected.to.line.below") + Spacer() + + Picker("", selection: Binding( + get: { appState.selectedNetworkAdapterName }, + set: { appState.selectedNetworkAdapterName = $0 } + )) { + Text("Auto").tag(nil as String?) + ForEach(availableAdapters, id: \.name) { adapter in + Text("\(adapter.name) (\(adapter.address))").tag(Optional(adapter.name)) + } + } + .pickerStyle(MenuPickerStyle()) + } + .onAppear { + availableAdapters = WebSocketServer.shared.getAvailableNetworkAdapters() + currentIPAddress = WebSocketServer.shared.getLocalIPAddress(adapterName: appState.selectedNetworkAdapterName) ?? "N/A" + } + .onChange(of: appState.selectedNetworkAdapterName) { _, _ in + currentIPAddress = WebSocketServer.shared.getLocalIPAddress(adapterName: appState.selectedNetworkAdapterName) ?? "N/A" + WebSocketServer.shared.stop() + if let port = UInt16(port) { + WebSocketServer.shared.start(port: port) + } else { + WebSocketServer.shared.start() + } + appState.shouldRefreshQR = true + } + + ConnectionInfoText( + label: "IP Address", + icon: "wifi", + text: currentIPAddress, + activeIp: appState.activeMacIp + ) + + HStack { + Label("Server Port", systemImage: "rectangle.connected.to.line.below") + .padding(.trailing, 20) + Spacer() + TextField("Server Port", text: $port) + .textFieldStyle(.roundedBorder) + .onChange(of: port) { oldValue, newValue in + port = newValue.filter { "0123456789".contains($0) } + } + .frame(maxWidth: 100) + } + } + .padding() + .glassBoxIfAvailable(radius: 18) + + HStack { + Spacer() + SaveAndRestartButton( + title: "Save and Restart the Server", + systemImage: "square.and.arrow.down.badge.checkmark", + deviceName: deviceName, + port: port, + version: appState.device?.version ?? "", + onSave: nil, + onRestart: nil + ) + } + } + .padding() + } + .onAppear { + if let device = appState.myDevice { + deviceName = device.name + port = String(device.port) + } else { + deviceName = UserDefaults.standard.string(forKey: "deviceName") + ?? (Host.current().localizedName ?? "My Mac") + port = UserDefaults.standard.string(forKey: "devicePort") + ?? String(Defaults.serverPort) + } + } + } +} diff --git a/airsync-mac/Screens/Settings/QuickShareSettingsView.swift b/airsync-mac/Screens/Settings/QuickShareSettingsView.swift new file mode 100644 index 00000000..7396a713 --- /dev/null +++ b/airsync-mac/Screens/Settings/QuickShareSettingsView.swift @@ -0,0 +1,125 @@ +import SwiftUI + +struct QuickShareSettingsView: View { + @ObservedObject var appState = AppState.shared + @State private var showingPlusPopover = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsHeaderView(title: "Quick Share", icon: "laptopcomputer.and.arrow.down") + VStack { + HStack { + Label(Localizer.shared.text("quickshare.title"), systemImage: "bolt.horizontal.circle") + Spacer() + Toggle("", isOn: $appState.quickShareEnabled) + .toggleStyle(.switch) + } + + if appState.quickShareEnabled { + Text(String(format: Localizer.shared.text("quickshare.settings.discoverable"), QuickShareManager.shared.deviceName)) + .font(.caption) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Label(Localizer.shared.text("quickshare.settings.autoAccept"), systemImage: "checkmark.shield") + Spacer() + Toggle("", isOn: $appState.autoAcceptQuickShare) + .toggleStyle(.switch) + } + + HStack { + Label(Localizer.shared.text("quickshare.settings.popupSharedImages"), systemImage: "doc.on.doc") + Spacer() + Toggle("", isOn: $appState.popupSharedImages) + .toggleStyle(.switch) + } + + if appState.popupSharedImages { + VStack(alignment: .leading, spacing: 6) { + HStack { + Label(Localizer.shared.text("quickshare.settings.maxPopups"), systemImage: "square.3.stack.3d") + .padding(.leading, 12) + Spacer() + HStack(spacing: 8) { + Text("\(appState.sharedImagePopupsLimit)") + .font(.system(.body, design: .monospaced)) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .frame(width: 24, alignment: .trailing) + Slider( + value: Binding( + get: { Double(appState.sharedImagePopupsLimit) }, + set: { appState.sharedImagePopupsLimit = Int(round($0)) } + ), + in: 1...10, + step: 1 + ) + .frame(width: 120) + } + } + } + .padding(.bottom, 4) + + HStack { + Label(Localizer.shared.text("quickshare.settings.popupSide"), systemImage: "macwindow.and.ipad.arrow.left") + .padding(.leading, 12) + Spacer() + Picker("", selection: $appState.popupSharedImagesOnLeft) { + Text(Localizer.shared.text("quickshare.settings.side.left")).tag(true) + Text(Localizer.shared.text("quickshare.settings.side.right")).tag(false) + } + .pickerStyle(.segmented) + } + } + } + } + .padding() + .glassBoxIfAvailable(radius: 18) + + SettingsHeaderView(title: Localizer.shared.text("settings.fileAccess.title"), icon: "folder.badge.gearshape") + VStack(alignment: .leading, spacing: 8) { + HStack { + ZStack { + HStack { + Label(Localizer.shared.text("settings.fileAccess.enabled"), systemImage: "externaldrive") + Spacer() + Toggle("", isOn: $appState.isFileAccessEnabled) + .toggleStyle(.switch) + .disabled(!AppState.shared.isPlus && AppState.shared.licenseCheck) + } + + if !AppState.shared.isPlus && AppState.shared.licenseCheck { + HStack { + Spacer() + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + showingPlusPopover = true + } + .frame(width: 500) + } + } + } + } + .popover(isPresented: $showingPlusPopover, arrowEdge: .bottom) { + PlusFeaturePopover(message: "File Access feature is available in AirSync+") + .onTapGesture { + showingPlusPopover = false + } + } + + Text(Localizer.shared.text("settings.fileAccess.description")) + .font(.caption) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding() + .glassBoxIfAvailable(radius: 18) + } + .padding() + } + } +} diff --git a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift index 56cf31f4..0be14d84 100644 --- a/airsync-mac/Screens/Settings/SettingsFeaturesView.swift +++ b/airsync-mac/Screens/Settings/SettingsFeaturesView.swift @@ -11,7 +11,6 @@ import Foundation struct SettingsFeaturesView: View { @ObservedObject var appState = AppState.shared - @AppStorage("scrcpyShareRes") private var scrcpyShareRes = false @AppStorage("scrcpyOnTop") private var scrcpyOnTop = false @AppStorage("stayAwake") private var stayAwake = false @AppStorage("turnScreenOff") private var turnScreenOff = false @@ -19,9 +18,11 @@ struct SettingsFeaturesView: View { @AppStorage("manualPosition") private var manualPosition = false @AppStorage("continueApp") private var continueApp = false @AppStorage("directKeyInput") private var directKeyInput = true + @AppStorage("showInControlCenter") private var showInControlCenter = false @AppStorage("scrcpyDesktopDpi") private var scrcpyDesktopDpi = "" @State private var showingPlusPopover = false + @State private var showControlCenterInfo = false @State private var tempBitrate: Double = 4.00 @State private var tempResolution: Double = 1200.00 @State private var isDragging = false @@ -225,30 +226,13 @@ struct SettingsFeaturesView: View { SettingsToggleView(name: "Direct keyboard input", icon: "keyboard.chevron.compact.down", isOn: $directKeyInput) - SettingsToggleView(name: "Apps & Desktop mode shared resolution", icon: "ipad.sizes", isOn: $scrcpyShareRes) - - HStack { - Text(UserDefaults.standard.scrcpyShareRes ? "Desktop and App mirroring" :"Desktop mode") - Spacer() - - Picker("", selection: Binding( - get: { UserDefaults.standard.scrcpyDesktopMode }, - set: { UserDefaults.standard.scrcpyDesktopMode = $0 } - )) { - Text("2560x1440").tag("2560x1440") - Text("2560x1600").tag("2560x1600") - Text("2000x1800").tag("2000x1800") - } - .pickerStyle(MenuPickerStyle()) - } - HStack { Text("dpi") Spacer() TextField("dpi", text: Binding( - get: { UserDefaults.standard.string(forKey: "scrcpyDesktopDpi") ?? "" }, + get: { UserDefaults.standard.scrcpyDesktopDpi }, set: { newValue in - UserDefaults.standard.set(newValue.filter { "0123456789".contains($0) }, forKey: "scrcpyDesktopDpi") + UserDefaults.standard.scrcpyDesktopDpi = newValue.filter { "0123456789".contains($0) } } )) .textFieldStyle(.roundedBorder) @@ -307,8 +291,7 @@ struct SettingsFeaturesView: View { } .padding() - .background(.background.opacity(0.3)) - .cornerRadius(12.0) + .glassBoxIfAvailable(radius: 18) .onAppear{ xCoords = UserDefaults.standard.manualPositionCoords[0] yCoords = UserDefaults.standard.manualPositionCoords[1] @@ -328,8 +311,7 @@ struct SettingsFeaturesView: View { .opacity(appState.isClipboardSyncEnabled ? 1.0 : 0.5) } .padding() - .background(.background.opacity(0.3)) - .cornerRadius(12.0) + .glassBoxIfAvailable(radius: 18) // Notifications VStack{ @@ -372,10 +354,33 @@ struct SettingsFeaturesView: View { } SettingsToggleView(name: "Send now playing status", icon: "play.circle", isOn: $appState.sendNowPlayingStatus) + + HStack { + Label("Show in Control Center", systemImage: "slider.horizontal.below.rectangle") + Button(action: { showControlCenterInfo = true }) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .alert("Show in Control Center", isPresented: $showControlCenterInfo) { + Button("OK", role: .cancel) {} + } message: { + Text("This feature plays a silent audio track in background in order to show up in macOS media. This may prevent your multi-device bluetooth audio devices to not switch correctly.") + } + Spacer() + Toggle("", isOn: $showInControlCenter) + .toggleStyle(.switch) + .onChange(of: showInControlCenter) { _, enabled in + if enabled { + NowPlayingPublisher.shared.enableSilentAudio() + } else { + NowPlayingPublisher.shared.disableSilentAudio() + } + } + } } .padding() - .background(.background.opacity(0.3)) - .cornerRadius(12.0) + .glassBoxIfAvailable(radius: 18) .onAppear{ checkNotificationPermissions() } @@ -401,8 +406,7 @@ struct SettingsFeaturesView: View { SettingsToggleView(name: "Ring for calls", icon: "speaker.wave.3", isOn: $appState.ringForCalls) } .padding() - .background(.background.opacity(0.3)) - .cornerRadius(12.0) + .glassBoxIfAvailable(radius: 18) } } diff --git a/airsync-mac/Screens/Settings/SettingsPlusView.swift b/airsync-mac/Screens/Settings/SettingsPlusView.swift index 2eaf4d78..891b6ce3 100644 --- a/airsync-mac/Screens/Settings/SettingsPlusView.swift +++ b/airsync-mac/Screens/Settings/SettingsPlusView.swift @@ -14,6 +14,7 @@ struct SettingsPlusView: View { @State private var licenseKey: String = "" @State private var isCheckingLicense = false @State private var licenseValid: Bool? = nil + @State private var isCheckingValidity = false @State private var isExpanded: Bool = false @State private var isLicenseVisible = false @@ -211,6 +212,31 @@ struct SettingsPlusView: View { } } } + + Divider() + + HStack { + Spacer() + + if isCheckingValidity { + ProgressView() + .controlSize(.small) + .scaleEffect(0.8) + } + + GlassButtonView( + label: isCheckingValidity ? "Checking..." : "Check Validity", + systemImage: "arrow.clockwise", + action: { + isCheckingValidity = true + Task { + await Gumroad().checkLicense() + isCheckingValidity = false + } + } + ) + .disabled(isCheckingValidity) + } } .padding() .background(Color.secondary.opacity(0.05)) diff --git a/airsync-mac/Screens/Settings/SettingsSidebarView.swift b/airsync-mac/Screens/Settings/SettingsSidebarView.swift new file mode 100644 index 00000000..ad2fd749 --- /dev/null +++ b/airsync-mac/Screens/Settings/SettingsSidebarView.swift @@ -0,0 +1,119 @@ +// +// SettingsSidebarView.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-05-20. +// + +import SwiftUI + +struct SettingsSidebarView: View { + @ObservedObject var appState = AppState.shared + @State private var hoveredTab: SettingsTab? = nil + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + Text("Settings") + .font(.system(size: 20, weight: .bold)) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 12) + + ScrollView { + VStack(spacing: 4) { + ForEach(SettingsTab.allCases) { tab in + categoryRow(for: tab) + } + } + .padding(.horizontal, 8) + } + + Spacer() + + HStack { + Spacer() + Text("AirSync v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.0.0")") + .font(.caption2) + .foregroundColor(.secondary) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .glassBoxIfAvailable(radius: 32) + Spacer() + } + .padding(.bottom, 12) + } + .frame(minWidth: 260) + } + + @ViewBuilder + private func categoryRow(for tab: SettingsTab) -> some View { + let isSelected = appState.selectedSettingsTab == tab + let isHovered = hoveredTab == tab + + Button { + withAnimation(.easeInOut(duration: 0.15)) { + appState.selectedSettingsTab = tab + } + } label: { + HStack(spacing: 12) { + // Circular icon badge + ZStack { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(Color.accentColor) + .frame(width: 26, height: 26) + + Image(systemName: tab.icon) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white) + } + + VStack(alignment: .leading, spacing: 1) { + Text(tab.displayName) + .font(.system(size: 13, weight: isSelected ? .semibold : .medium)) + .foregroundColor(isSelected ? .primary : .primary.opacity(0.85)) + + if tab == .myMac { + Text(DeviceTypeUtil.deviceFullDescription()) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + + Spacer() + + // Special trailing indicator icons/pills + if tab == .mirroring && !appState.isPlus { + Image(systemName: "lock.fill") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } else if tab == .airsyncPlus { + if appState.isPlus { + Image(systemName: "heart.fill") + .font(.system(size: 11)) + .foregroundColor(.accentColor) + } else { + Text("Get") + .font(.system(size: 9, weight: .bold)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .glassBoxIfAvailable(radius: 32) + .tint(Color.accentColor) + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(isSelected ? Color.accentColor.opacity(0.35) : (isHovered ? Color.secondary.opacity(0.08) : Color.clear)) + ) + } + .buttonStyle(PlainButtonStyle()) + .onHover { hovering in + hoveredTab = hovering ? tab : nil + } + } +} diff --git a/airsync-mac/Screens/Settings/SettingsView.swift b/airsync-mac/Screens/Settings/SettingsView.swift index a7a8ce9b..147ec309 100644 --- a/airsync-mac/Screens/Settings/SettingsView.swift +++ b/airsync-mac/Screens/Settings/SettingsView.swift @@ -2,273 +2,26 @@ import SwiftUI struct SettingsView: View { @ObservedObject var appState = AppState.shared - @AppStorage("SUEnableAutomaticChecks") private var automaticallyChecksForUpdates = true - @AppStorage("SUAutomaticallyUpdate") private var automaticallyDownloadsUpdates = false - - @State private var deviceName: String = "" - @State private var port: String = "6996" - @State private var availableAdapters: [(name: String, address: String)] = [] - @State private var currentIPAddress: String = "N/A" - @State private var showRemoteSheet = false - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - // 1. Device - VStack { - DeviceNameView(deviceName: $deviceName) - } - .padding() - .background(.background.opacity(0.3)) - .cornerRadius(12.0) - - // 2. Server - headerSection(title: "Server", icon: "server.rack") - VStack(spacing: 12) { - HStack { - Label("Network Adapter", systemImage: "rectangle.connected.to.line.below") - Spacer() - - Picker("", selection: Binding( - get: { appState.selectedNetworkAdapterName }, - set: { appState.selectedNetworkAdapterName = $0 } - )) { - Text("Auto").tag(nil as String?) - ForEach(availableAdapters, id: \.name) { adapter in - Text("\(adapter.name) (\(adapter.address))").tag(Optional(adapter.name)) - } - } - .pickerStyle(MenuPickerStyle()) - } - .onAppear { - availableAdapters = WebSocketServer.shared.getAvailableNetworkAdapters() - currentIPAddress = WebSocketServer.shared.getLocalIPAddress(adapterName: appState.selectedNetworkAdapterName) ?? "N/A" - } - .onChange(of: appState.selectedNetworkAdapterName) { _, _ in - currentIPAddress = WebSocketServer.shared.getLocalIPAddress(adapterName: appState.selectedNetworkAdapterName) ?? "N/A" - WebSocketServer.shared.stop() - if let port = UInt16(port) { - WebSocketServer.shared.start(port: port) - } else { - WebSocketServer.shared.start() - } - appState.shouldRefreshQR = true - } - - ConnectionInfoText( - label: "IP Address", - icon: "wifi", - text: currentIPAddress, - activeIp: appState.activeMacIp - ) - - HStack { - Label("Server Port", systemImage: "rectangle.connected.to.line.below") - .padding(.trailing, 20) - Spacer() - TextField("Server Port", text: $port) - .textFieldStyle(.roundedBorder) - .onChange(of: port) { oldValue, newValue in - port = newValue.filter { "0123456789".contains($0) } - } - .frame(maxWidth: 100) - } - - HStack { - Label("Fallback to mdns services", systemImage: "antenna.radiowaves.left.and.right") - Spacer() - Toggle("", isOn: $appState.fallbackToMdns) - .toggleStyle(.switch) - } - } - .padding() - .background(.background.opacity(0.3)) - .cornerRadius(12.0) - - HStack { - Spacer() - SaveAndRestartButton( - title: "Save and Restart the Server", - systemImage: "square.and.arrow.down.badge.checkmark", - deviceName: deviceName, - port: port, - version: appState.device?.version ?? "", - onSave: nil, - onRestart: nil - ) - } - - // 2. Features - headerSection(title: "Features", icon: "square.grid.2x2") - SettingsFeaturesView() - - VStack { - HStack { - Label("Remote Control Permission", systemImage: "accessibility") - Spacer() - GlassButtonView(label: "Configure", systemImage: "gearshape"){ - showRemoteSheet = true - } - } - } - .padding() - .background(.background.opacity(0.3)) - .cornerRadius(12.0) - .sheet(isPresented: $showRemoteSheet) { - RemotePermissionView() - } - - - // 3. Quick Share - headerSection(title: "Quick Share", icon: "laptopcomputer.and.arrow.down") - VStack { - HStack { - Label(Localizer.shared.text("quickshare.title"), systemImage: "bolt.horizontal.circle") - Spacer() - Toggle("", isOn: $appState.quickShareEnabled) - .toggleStyle(.switch) - } - - if appState.quickShareEnabled { - Text(String(format: Localizer.shared.text("quickshare.settings.discoverable"), QuickShareManager.shared.deviceName)) - .font(.caption) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - - HStack { - Label(Localizer.shared.text("quickshare.settings.autoAccept"), systemImage: "checkmark.shield") - Spacer() - Toggle("", isOn: $appState.autoAcceptQuickShare) - .toggleStyle(.switch) - } - - } - } - .padding() - .background(.background.opacity(0.3)) - .cornerRadius(12.0) - - // 4. Appearance - headerSection(title: "Appearance", icon: "paintbrush") - VStack(spacing: 12) { - HStack{ - Label("Liquid Opacity", systemImage: "app.background.dotted") - Spacer() - Slider( - value: $appState.windowOpacity, - in: 0...1.0 - ) - .frame(width: 150) - } - - HStack{ - Label("Hide Dock Icon", systemImage: "dock.rectangle") - Spacer() - Toggle("", isOn: $appState.hideDockIcon) - .toggleStyle(.switch) - } - - HStack{ - Label("Always Open Window", systemImage: "macwindow") - Spacer() - Toggle("", isOn: $appState.alwaysOpenWindow) - .toggleStyle(.switch) - } - } - .padding() - .background(.background.opacity(0.3)) - .cornerRadius(12.0) - - // 4. Menu Bar - headerSection(title: "Menu Bar", icon: "menubar.arrow.up.rectangle") - VStack(spacing: 12) { - HStack{ - Label("Show Menu Bar Text", systemImage: "text.alignleft") - Spacer() - Toggle("", isOn: $appState.showMenubarText) - .toggleStyle(.switch) - } - - if appState.showMenubarText { - VStack(spacing: 12) { - - HStack { - Label("Max Length", systemImage: "arrow.left.and.right") - Spacer() - Slider( - value: Binding( - get: { Double(appState.menubarTextMaxLength) }, - set: { appState.menubarTextMaxLength = Int($0) } - ), - in: 10...80, - step: 5 - ) - .frame(width: 150) - .controlSize(.small) - } - - HStack{ - Label("Show Device Name", systemImage: "iphone.gen3") - Spacer() - Toggle("", isOn: $appState.showMenubarDeviceName) - .toggleStyle(.switch) - } - } - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - .padding() - .background(.background.opacity(0.3)) - .cornerRadius(12.0) - - // 5. Application - headerSection(title: "Application", icon: "app.badge") - VStack(spacing: 12) { - SettingsToggleView(name: "Check for updates automatically", icon: "sparkles", isOn: $automaticallyChecksForUpdates) - SettingsToggleView(name: "Download updates automatically", icon: "arrow.down.circle", isOn: $automaticallyDownloadsUpdates) - SettingsToggleView(name: "Crash reporting", icon: "ant", isOn: $appState.isCrashReportingEnabled) - } - .padding() - .background(.background.opacity(0.3)) - .cornerRadius(12.0) - - // 6. AirSync+ - headerSection(title: "AirSync+", icon: "plus.diamond.fill") - SettingsPlusView() - .padding() - .background(.background.opacity(0.3)) - .cornerRadius(12.0) - } - .padding() - .animation(.spring(), value: appState.showMenubarText) - - } - .frame(minWidth: 300) - .onAppear { - if let device = appState.myDevice { - deviceName = device.name - port = String(device.port) - } else { - deviceName = UserDefaults.standard.string(forKey: "deviceName") - ?? (Host.current().localizedName ?? "My Mac") - port = UserDefaults.standard.string(forKey: "devicePort") - ?? String(Defaults.serverPort) + Group { + switch appState.selectedSettingsTab { + case .myMac: + MyMacSettingsView() + case .sync: + SyncSettingsView() + case .mirroring: + MirroringSettingsView() + case .quickShare: + QuickShareSettingsView() + case .menubar: + MenubarSettingsView() + case .appearance: + AppearanceSettingsView() + case .airsyncPlus: + AirSyncPlusSettingsView() } } - } - - - @ViewBuilder - private func headerSection(title: String, icon: String) -> some View { - HStack(spacing: 8) { - Image(systemName: icon) - .foregroundStyle(Color.accentColor) - Text(title) - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(.secondary) - Spacer() - } - .padding(.horizontal, 8) + .frame(minWidth: 300) } } diff --git a/airsync-mac/Screens/Settings/SyncSettingsView.swift b/airsync-mac/Screens/Settings/SyncSettingsView.swift new file mode 100644 index 00000000..55d3dd29 --- /dev/null +++ b/airsync-mac/Screens/Settings/SyncSettingsView.swift @@ -0,0 +1,301 @@ +// +// SyncSettingsView.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-05-20. +// + +import SwiftUI +import UserNotifications + +struct SyncSettingsView: View { + @ObservedObject var appState = AppState.shared + @State private var showingPlusPopover = false + @State private var showRemoteSheet = false + + @AppStorage("showInControlCenter") private var showInControlCenter = false + @State private var showControlCenterInfo = false + + // State for notification permissions + @State private var notificationsGranted = false + @State private var notificationsChecked = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // 1. Wireless / Wired ADB + headerSection(title: "Connection & ADB", icon: "bolt.horizontal.circle") + VStack(spacing: 12) { + ZStack { + HStack { + Label("Auto connect ADB", systemImage: "bolt.horizontal.circle") + Spacer() + + if appState.adbConnected { + GlassButtonView( + label: "Disconnect ADB", + systemImage: "stop.circle", + action: { + ADBConnector.disconnectADB() + appState.adbConnected = false + } + ) + } else { + GlassButtonView( + label: appState.adbConnecting ? "Connecting..." : "Connect ADB", + systemImage: appState.adbConnecting ? "hourglass" : "play.circle", + action: { + if !appState.adbConnecting { + appState.adbConnectionResult = "" // Clear console + appState.manualAdbConnectionPending = true + WebSocketServer.shared.sendRefreshAdbPortsRequest() + appState.adbConnectionResult = "Refreshing latest ADB ports from device..." + } + } + ) + .disabled( + appState.device == nil || appState.adbConnecting || !AppState.shared.isPlus + ) + } + + ZStack { + Toggle( + "", + isOn: $appState.adbEnabled + ) + .labelsHidden() + .toggleStyle(.switch) + .disabled(!AppState.shared.isPlus && AppState.shared.licenseCheck) + } + .frame(width: 55) + } + + if !AppState.shared.isPlus && AppState.shared.licenseCheck { + HStack { + Spacer() + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + showingPlusPopover = true + } + .frame(width: 500) + } + } + } + .popover(isPresented: $showingPlusPopover, arrowEdge: .bottom) { + PlusFeaturePopover(message: "Wireless and Wired ADB features are available in AirSync+") + .onTapGesture { + showingPlusPopover = false + } + } + + HStack { + Label("Fallback to mdns services", systemImage: "antenna.radiowaves.left.and.right") + Spacer() + Toggle("", isOn: $appState.fallbackToMdns) + .toggleStyle(.switch) + } + + if let result = appState.adbConnectionResult { + VStack(alignment: .leading, spacing: 6) { + ExpandableLicenseSection(title: "ADB Console", content: "[" + (UserDefaults.standard.lastADBCommand ?? "[]") + "] " + result, copyable: true) + } + } + + HStack { + ZStack { + HStack { + Label(L("settings.wiredAdb"), systemImage: "cable.connector") + Spacer() + Toggle("", isOn: $appState.wiredAdbEnabled) + .toggleStyle(.switch) + .disabled(!AppState.shared.isPlus && AppState.shared.licenseCheck) + } + + if !AppState.shared.isPlus && AppState.shared.licenseCheck { + HStack { + Spacer() + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + showingPlusPopover = true + } + .frame(width: 500) + } + } + } + } + + HStack { + Label("Suppress failed messages", systemImage: "bell.slash") + Spacer() + Toggle("", isOn: $appState.suppressAdbFailureAlerts) + .toggleStyle(.switch) + } + } + .padding() + .glassBoxIfAvailable(radius: 18) + + // 2. Clipboard Sync + headerSection(title: "Clipboard Sync", icon: "clipboard") + VStack { + SettingsToggleView(name: "Sync clipboard", icon: "clipboard", isOn: $appState.isClipboardSyncEnabled) + + HStack { + Label("Auto-open shared links", systemImage: "link") + Spacer() + Toggle("", isOn: $appState.autoOpenLinks) + .toggleStyle(.switch) + .disabled(!appState.isClipboardSyncEnabled) + } + .opacity(appState.isClipboardSyncEnabled ? 1.0 : 0.5) + } + .padding() + .glassBoxIfAvailable(radius: 18) + + // 3. Notifications + headerSection(title: "Notifications Sync", icon: "bell.badge") + VStack { + SettingsToggleView(name: "Sync notification dismissals", icon: "bell.badge", isOn: $appState.dismissNotif) + + HStack { + Label("System Notifications", systemImage: "bell.badge") + Spacer() + + if notificationsGranted { + Picker("", selection: $appState.notificationSound) { + Text("Default").tag("default") + ForEach(SystemSounds.availableSounds, id: \.self) { sound in + Text(sound).tag(sound) + } + } + .pickerStyle(MenuPickerStyle()) + .frame(minWidth: 100) + + Button(action: { + SystemSounds.playSound(appState.notificationSound) + }) { + Image(systemName: "play.circle") + } + .buttonStyle(.borderless) + .help("Test notification sound") + } else { + GlassButtonView( + label: "Grant Permission", + systemImage: "bell.badge", + primary: true, + action: { + openNotificationSettings() + } + ) + } + } + + SettingsToggleView(name: "Send now playing status", icon: "play.circle", isOn: $appState.sendNowPlayingStatus) + + HStack { + Label("Show in Control Center", systemImage: "slider.horizontal.below.rectangle") + Button(action: { showControlCenterInfo = true }) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .alert("Show in Control Center", isPresented: $showControlCenterInfo) { + Button("OK", role: .cancel) {} + } message: { + Text("This feature plays a silent audio track in background in order to show up in macOS media. This may prevent your multi-device bluetooth audio devices to not switch correctly.") + } + Spacer() + Toggle("", isOn: $showInControlCenter) + .toggleStyle(.switch) + .onChange(of: showInControlCenter) { _, enabled in + if enabled { + NowPlayingPublisher.shared.enableSilentAudio() + } else { + NowPlayingPublisher.shared.disableSilentAudio() + } + } + } + } + .padding() + .glassBoxIfAvailable(radius: 18) + .onAppear { + checkNotificationPermissions() + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + checkNotificationPermissions() + } + + // 4. Call Alerts + headerSection(title: "Call Alerts", icon: "phone") + VStack { + HStack { + Label("Call Alert", systemImage: "phone") + Spacer() + + Picker("", selection: $appState.callNotificationMode) { + ForEach(CallNotificationMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + .pickerStyle(MenuPickerStyle()) + .frame(minWidth: 120) + } + + SettingsToggleView(name: "Ring for calls", icon: "speaker.wave.3", isOn: $appState.ringForCalls) + } + .padding() + .glassBoxIfAvailable(radius: 18) + + // 5. Remote Accessibility Control + headerSection(title: "Remote Accessibility", icon: "accessibility") + VStack { + HStack { + Label("Remote Control Permission", systemImage: "accessibility") + Spacer() + GlassButtonView(label: "Configure", systemImage: "gearshape") { + showRemoteSheet = true + } + } + } + .padding() + .glassBoxIfAvailable(radius: 18) + .sheet(isPresented: $showRemoteSheet) { + RemotePermissionView() + } + } + .padding() + } + } + + @ViewBuilder + private func headerSection(title: String, icon: String) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .foregroundStyle(Color.accentColor) + Text(title) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 8) + } + + // MARK: - Notification Permission Helpers + func checkNotificationPermissions() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + notificationsGranted = (settings.authorizationStatus == .authorized) + notificationsChecked = true + } + } + } + + func openNotificationSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") { + NSWorkspace.shared.open(url) + } + } +} diff --git a/airsync-mac/airsync_macApp.swift b/airsync-mac/airsync_macApp.swift index 56b14da9..000adef6 100644 --- a/airsync-mac/airsync_macApp.swift +++ b/airsync-mac/airsync_macApp.swift @@ -22,10 +22,13 @@ struct airsync_macApp: App { private let updaterController: SPUStandardUpdaterController // Initialize NowPlayingViewModel to start sending media info to Android - @StateObject private var macInfoSyncManager = MacInfoSyncManager() + @StateObject private var macInfoSyncManager = MacInfoSyncManager.shared init() { + // Initialize NowPlayingPublisher for MPNowPlayingInfoCenter integration + NowPlayingPublisher.shared.start() + let center = UNUserNotificationCenter.current() center.delegate = notificationDelegate updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) @@ -88,7 +91,7 @@ struct airsync_macApp: App { if !appState.isNativeMirroring { dismissWindow(id: "nativeMirror") } - if appState.activeCall == nil { + if appState.activeCall == nil || appState.callNotificationMode != .popup { dismissWindow(id: "callWindow") } if !appState.showingQuickShareTransfer { @@ -98,12 +101,21 @@ struct airsync_macApp: App { } } .onChange(of: appState.activeCall) { oldValue, newValue in - if newValue != nil { + if newValue != nil && appState.callNotificationMode == .popup { openWindow(id: "callWindow") } else { dismissWindow(id: "callWindow") } } + .onChange(of: appState.callNotificationMode) { oldValue, newValue in + if appState.activeCall != nil { + if newValue == .popup { + openWindow(id: "callWindow") + } else { + dismissWindow(id: "callWindow") + } + } + } .onChange(of: appState.showingQuickShareTransfer) { oldValue, newValue in if newValue { openWindow(id: "quickShareWindow") diff --git a/scrcpy-server b/scrcpy-server new file mode 100644 index 00000000..afc49c68 Binary files /dev/null and b/scrcpy-server differ diff --git a/scrcpy-server-v3.3.4 b/scrcpy-server-v3.3.4 deleted file mode 100644 index 89054b7e..00000000 Binary files a/scrcpy-server-v3.3.4 and /dev/null differ