Restored files from old Mac, force pushing
BIN
.DS_Store
vendored
Normal file
64
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Xcode
|
||||
#
|
||||
build/
|
||||
DerivedData/
|
||||
*.pbxuser
|
||||
!default.pbxuser
|
||||
*.mode1v3
|
||||
!default.mode1v3
|
||||
*.mode2v3
|
||||
!default.mode2v3
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
xcuserdata/
|
||||
*.xcscmblueprint
|
||||
|
||||
# SwiftPM
|
||||
#
|
||||
.build/
|
||||
Package.resolved
|
||||
|
||||
# CocoaPods
|
||||
#
|
||||
Pods/
|
||||
Podfile.lock
|
||||
|
||||
# Carthage
|
||||
#
|
||||
Carthage/Build/
|
||||
|
||||
# Fastlane
|
||||
#
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
|
||||
# Archives
|
||||
#
|
||||
*.xcarchive
|
||||
|
||||
# App data
|
||||
#
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
# Playgrounds
|
||||
#
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
# Xcode Server
|
||||
#
|
||||
integration.json
|
||||
|
||||
# Other
|
||||
#
|
||||
*.moved-aside
|
||||
*.xcuserstate
|
||||
|
||||
# GitHub instructions and agent files
|
||||
#
|
||||
.github/
|
||||
rough_note.md
|
||||
|
|
@ -1,51 +1,61 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "notch-stage-icon2 2.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "notch-stage-icon2 5.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "notch-stage-icon2 6.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "notch-stage-icon2 11.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "notch-stage-icon2 12.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "notch-stage-icon2 13.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "notch-stage-icon2 7.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "notch-stage-icon2 8.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "notch-stage-icon2 9.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "notch-stage-icon2 10.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 806 B |
|
After Width: | Height: | Size: 2 KiB |
|
After Width: | Height: | Size: 2 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 120 KiB |
23
DynamicIsland/Assets.xcassets/Github.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "GitHub Mark White.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "GitHub Mark White 1.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "GitHub Mark White 2.svg",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
1
DynamicIsland/Assets.xcassets/Github.imageset/GitHub Mark White 1.svg
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 960 B |
1
DynamicIsland/Assets.xcassets/Github.imageset/GitHub Mark White 2.svg
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 960 B |
1
DynamicIsland/Assets.xcassets/Github.imageset/GitHub Mark White.svg
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 960 B |
12
DynamicIsland/Assets.xcassets/bolt.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "bolt.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
DynamicIsland/Assets.xcassets/bolt.imageset/bolt.png
vendored
Normal file
|
After Width: | Height: | Size: 903 B |
23
DynamicIsland/Assets.xcassets/chrome.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Google Chrome macOS BigSur Icon.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Google Chrome macOS BigSur Icon 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Google Chrome macOS BigSur Icon 2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
DynamicIsland/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 1.png
vendored
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
DynamicIsland/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon 2.png
vendored
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
DynamicIsland/Assets.xcassets/chrome.imageset/Google Chrome macOS BigSur Icon.png
vendored
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
20
DynamicIsland/Assets.xcassets/defaultmusic.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
12
DynamicIsland/Assets.xcassets/dynamicisland.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "TheBoringTeam.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
3
DynamicIsland/Assets.xcassets/dynamicisland.imageset/TheBoringTeam.svg
vendored
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
DynamicIsland/Assets.xcassets/logo.imageset/256-mac 1.png
vendored
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
12
DynamicIsland/Assets.xcassets/logo.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "256-mac 1.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
DynamicIsland/Assets.xcassets/logo2.imageset/BoringNotch icon.png
vendored
Normal file
|
After Width: | Height: | Size: 409 KiB |
12
DynamicIsland/Assets.xcassets/logo2.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "BoringNotch icon.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
12
DynamicIsland/Assets.xcassets/plug.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "plug.png",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
DynamicIsland/Assets.xcassets/plug.imageset/plug.png
vendored
Normal file
|
After Width: | Height: | Size: 899 B |
12
DynamicIsland/Assets.xcassets/sparkle.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "sparkle.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
18
DynamicIsland/Assets.xcassets/sparkle.imageset/sparkle.svg
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_95_55)">
|
||||
<circle cx="13" cy="13" r="3" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_95_55" x="0.8" y="0.8" width="24.4" height="24.4" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="4" operator="dilate" in="SourceAlpha" result="effect1_dropShadow_95_55"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="2.6"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.25 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_95_55"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_95_55" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 973 B |
12
DynamicIsland/Assets.xcassets/spotlight.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "spotlight.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
35
DynamicIsland/Assets.xcassets/spotlight.imageset/spotlight.svg
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<svg width="689" height="1005" viewBox="0 0 689 1005" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.27" filter="url(#filter0_f_95_52)">
|
||||
<ellipse cx="344.5" cy="925.5" rx="339.5" ry="74.5" fill="url(#paint0_linear_95_52)"/>
|
||||
</g>
|
||||
<g opacity="0.3" filter="url(#filter1_f_95_52)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 923.5C85.0575 575.896 218.5 -4 218.5 -4H481.5L684 923.5H683.772C677.163 883.632 527.795 851.757 344.5 851.757C161.205 851.757 11.8371 883.632 5.22814 923.5H5Z" fill="url(#paint1_linear_95_52)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 923.5C85.0575 575.896 218.5 -4 218.5 -4H481.5L684 923.5H683.772C677.163 883.632 527.795 851.757 344.5 851.757C161.205 851.757 11.8371 883.632 5.22814 923.5H5Z" fill="url(#paint2_linear_95_52)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_95_52" x="0" y="846" width="689" height="159" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="2.5" result="effect1_foregroundBlur_95_52"/>
|
||||
</filter>
|
||||
<filter id="filter1_f_95_52" x="0" y="-9" width="689" height="937.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="2.5" result="effect1_foregroundBlur_95_52"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_95_52" x1="-579.5" y1="1205.5" x2="-232.344" y2="1580.76" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E1E1E1"/>
|
||||
<stop offset="1" stop-color="#3B3B3B"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_95_52" x1="342.5" y1="-4" x2="342.5" y2="924.669" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#D7D7D7"/>
|
||||
<stop offset="1" stop-color="#252525"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_95_52" x1="143" y1="338.48" x2="567.46" y2="361.604" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white" stop-opacity="0"/>
|
||||
<stop offset="0.24" stop-color="white" stop-opacity="0.21045"/>
|
||||
<stop offset="0.53" stop-color="#262626" stop-opacity="0.3657"/>
|
||||
<stop offset="1" stop-color="#2E2E2E" stop-opacity="0.69"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -1,24 +1,521 @@
|
|||
//
|
||||
// ContentView.swift
|
||||
// DynamicIsland
|
||||
// boringNotchApp
|
||||
//
|
||||
// Created by admin63 on 01/11/24.
|
||||
// Created by Harsh Vardhan Goswami on 02/08/24
|
||||
// Modified by Richard Kunkli on 24/08/2024.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import Defaults
|
||||
import KeyboardShortcuts
|
||||
import SwiftUI
|
||||
import SwiftUIIntrospect
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@StateObject var webcamManager: WebcamManager = .init()
|
||||
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@ObservedObject var musicManager = MusicManager.shared
|
||||
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
||||
|
||||
@State private var isHovering: Bool = false
|
||||
@State private var hoverWorkItem: DispatchWorkItem?
|
||||
@State private var debounceWorkItem: DispatchWorkItem?
|
||||
|
||||
@State private var isHoverStateChanging: Bool = false
|
||||
|
||||
@State private var gestureProgress: CGFloat = .zero
|
||||
|
||||
@State private var haptics: Bool = false
|
||||
|
||||
@Namespace var albumArtNamespace
|
||||
|
||||
@Default(.useMusicVisualizer) var useMusicVisualizer
|
||||
|
||||
@Default(.showNotHumanFace) var showNotHumanFace
|
||||
@Default(.useModernCloseAnimation) var useModernCloseAnimation
|
||||
|
||||
private let extendedHoverPadding: CGFloat = 30
|
||||
private let zeroHeightHoverPadding: CGFloat = 10
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Image(systemName: "globe")
|
||||
.imageScale(.large)
|
||||
.foregroundStyle(.tint)
|
||||
Text("Hello, world!")
|
||||
ZStack(alignment: .top) {
|
||||
let mainLayout = NotchLayout()
|
||||
.frame(alignment: .top)
|
||||
.padding(.horizontal, vm.notchState == .open
|
||||
? Defaults[.cornerRadiusScaling] ? (cornerRadiusInsets.opened.top - 5) : (cornerRadiusInsets.opened.bottom - 5)
|
||||
: cornerRadiusInsets.closed.bottom
|
||||
)
|
||||
.padding([.horizontal, .bottom], vm.notchState == .open ? 12 : 0)
|
||||
.background(.black)
|
||||
.mask {
|
||||
((vm.notchState == .open) && Defaults[.cornerRadiusScaling])
|
||||
? NotchShape(topCornerRadius: cornerRadiusInsets.opened.top, bottomCornerRadius: cornerRadiusInsets.opened.bottom)
|
||||
.drawingGroup()
|
||||
: NotchShape(topCornerRadius: cornerRadiusInsets.closed.top, bottomCornerRadius: cornerRadiusInsets.closed.bottom)
|
||||
.drawingGroup()
|
||||
}
|
||||
.padding(.bottom, vm.notchState == .open && Defaults[.extendHoverArea] ? 0 : (vm.effectiveClosedNotchHeight == 0)
|
||||
? zeroHeightHoverPadding
|
||||
: 0
|
||||
)
|
||||
|
||||
mainLayout
|
||||
.conditionalModifier(!useModernCloseAnimation) { view in
|
||||
let hoverAnimationAnimation = Animation.bouncy.speed(1.2)
|
||||
let notchStateAnimation = Animation.spring.speed(1.2)
|
||||
return view
|
||||
.animation(hoverAnimationAnimation, value: isHovering)
|
||||
.animation(notchStateAnimation, value: vm.notchState)
|
||||
.animation(.smooth, value: gestureProgress)
|
||||
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
|
||||
}
|
||||
.conditionalModifier(useModernCloseAnimation) { view in
|
||||
let hoverAnimationAnimation = Animation.bouncy.speed(1.2)
|
||||
let notchStateAnimation = Animation.spring.speed(1.2)
|
||||
return view
|
||||
.animation(hoverAnimationAnimation, value: isHovering)
|
||||
.animation(notchStateAnimation, value: vm.notchState)
|
||||
}
|
||||
.conditionalModifier(Defaults[.openNotchOnHover]) { view in
|
||||
view.onHover { hovering in
|
||||
handleHover(hovering)
|
||||
}
|
||||
}
|
||||
.conditionalModifier(!Defaults[.openNotchOnHover]) { view in
|
||||
view
|
||||
.onHover { hovering in
|
||||
withAnimation(vm.animation) {
|
||||
isHovering = hovering
|
||||
}
|
||||
|
||||
// Only close if mouse leaves and the notch is open
|
||||
if !hovering && vm.notchState == .open {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
if (vm.notchState == .closed) && Defaults[.enableHaptics] {
|
||||
haptics.toggle()
|
||||
}
|
||||
doOpen()
|
||||
}
|
||||
.conditionalModifier(Defaults[.enableGestures]) { view in
|
||||
view
|
||||
.panGesture(direction: .down) { translation, phase in
|
||||
handleDownGesture(translation: translation, phase: phase)
|
||||
}
|
||||
}
|
||||
}
|
||||
.conditionalModifier(Defaults[.closeGestureEnabled] && Defaults[.enableGestures]) { view in
|
||||
view
|
||||
.panGesture(direction: .up) { translation, phase in
|
||||
handleUpGesture(translation: translation, phase: phase)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
withAnimation(vm.animation) {
|
||||
if coordinator.firstLaunch {
|
||||
doOpen()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.onChange(of: vm.notchState) { _, newState in
|
||||
// Reset hover state when notch state changes
|
||||
if newState == .closed && isHovering {
|
||||
// Only reset visually, without triggering the hover logic again
|
||||
isHoverStateChanging = true
|
||||
withAnimation {
|
||||
isHovering = false
|
||||
}
|
||||
// Reset the flag after the animation completes
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
isHoverStateChanging = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: vm.isBatteryPopoverActive) { _, newPopoverState in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
if !newPopoverState && !isHovering && vm.notchState == .open {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sensoryFeedback(.alignment, trigger: haptics)
|
||||
.contextMenu {
|
||||
SettingsLink(label: {
|
||||
Text("Settings")
|
||||
})
|
||||
.keyboardShortcut(KeyEquivalent(","), modifiers: .command)
|
||||
// Button("Edit") { // Doesnt work....
|
||||
// let dn = DynamicNotch(content: EditPanelView())
|
||||
// dn.toggle()
|
||||
// }
|
||||
// #if DEBUG
|
||||
// .disabled(false)
|
||||
// #else
|
||||
// .disabled(true)
|
||||
// #endif
|
||||
// .keyboardShortcut("E", modifiers: .command)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: openNotchSize.width, maxHeight: openNotchSize.height, alignment: .top)
|
||||
.shadow(color: ((vm.notchState == .open || isHovering) && Defaults[.enableShadow]) ? .black.opacity(0.6) : .clear, radius: Defaults[.cornerRadiusScaling] ? 10 : 5)
|
||||
.background(dragDetector)
|
||||
.environmentObject(vm)
|
||||
.environmentObject(webcamManager)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func NotchLayout() -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
VStack(alignment: .leading) {
|
||||
if coordinator.firstLaunch {
|
||||
Spacer()
|
||||
HelloAnimation().frame(width: 200, height: 80).onAppear(perform: {
|
||||
vm.closeHello()
|
||||
})
|
||||
.padding(.top, 40)
|
||||
Spacer()
|
||||
} else {
|
||||
if coordinator.expandingView.type == .battery && coordinator.expandingView.show && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] {
|
||||
HStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(batteryModel.statusText)
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
Rectangle()
|
||||
.fill(.black)
|
||||
.frame(width: vm.closedNotchSize.width + 10)
|
||||
|
||||
HStack {
|
||||
DynamicIslandBatteryView(
|
||||
batteryWidth: 30,
|
||||
isCharging: batteryModel.isCharging,
|
||||
isInLowPowerMode: batteryModel.isInLowPowerMode,
|
||||
isPluggedIn: batteryModel.isPluggedIn,
|
||||
levelBattery: batteryModel.levelBattery,
|
||||
isForNotification: true
|
||||
)
|
||||
}
|
||||
.frame(width: 76, alignment: .trailing)
|
||||
}
|
||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||
} else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) {
|
||||
InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
|
||||
.transition(.opacity)
|
||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed {
|
||||
MusicLiveActivity()
|
||||
} else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed {
|
||||
DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle)
|
||||
} else if vm.notchState == .open {
|
||||
DynamicIslandHeader()
|
||||
.frame(height: max(24, vm.effectiveClosedNotchHeight))
|
||||
.blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0)
|
||||
.animation(.spring(response: 1, dampingFraction: 1, blendDuration: 0.8), value: vm.notchState)
|
||||
} else {
|
||||
Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: vm.effectiveClosedNotchHeight)
|
||||
}
|
||||
|
||||
if coordinator.sneakPeek.show {
|
||||
if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && !Defaults[.inlineHUD] {
|
||||
SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in
|
||||
//
|
||||
})
|
||||
.padding(.bottom, 10)
|
||||
.padding(.leading, 4)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
// Old sneak peek music
|
||||
else if coordinator.sneakPeek.type == .music {
|
||||
if vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard {
|
||||
HStack(alignment: .center) {
|
||||
Image(systemName: "music.note")
|
||||
GeometryReader { geo in
|
||||
MarqueeText(.constant(musicManager.songTitle + " - " + musicManager.artistName), textColor: .gray, minDuration: 1, frameWidth: geo.size.width)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.conditionalModifier((coordinator.sneakPeek.show && (coordinator.sneakPeek.type == .music) && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.sneakPeek.show && (coordinator.sneakPeek.type != .music) && (vm.notchState == .closed))) { view in
|
||||
view
|
||||
.fixedSize()
|
||||
}
|
||||
.zIndex(2)
|
||||
|
||||
ZStack {
|
||||
if vm.notchState == .open {
|
||||
switch coordinator.currentView {
|
||||
case .home:
|
||||
NotchHomeView(albumArtNamespace: albumArtNamespace)
|
||||
case .shelf:
|
||||
NotchShelfView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.zIndex(1)
|
||||
.allowsHitTesting(vm.notchState == .open)
|
||||
.blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0)
|
||||
.opacity(abs(gestureProgress) > 0.3 ? min(abs(gestureProgress * 2), 0.8) : 1)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func DynamicIslandFaceAnimation() -> some View {
|
||||
HStack {
|
||||
HStack {
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12))
|
||||
Rectangle()
|
||||
.fill(.black)
|
||||
.frame(width: vm.closedNotchSize.width - 20)
|
||||
MinimalFaceFeatures()
|
||||
}
|
||||
}.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func MusicLiveActivity() -> some View {
|
||||
HStack {
|
||||
HStack {
|
||||
Color.clear
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.background(
|
||||
Image(nsImage: musicManager.albumArt)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed))
|
||||
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
|
||||
.frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12))
|
||||
}
|
||||
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)))
|
||||
|
||||
Rectangle()
|
||||
.fill(.black)
|
||||
.overlay(
|
||||
HStack(alignment: .top){
|
||||
if(coordinator.expandingView.show && coordinator.expandingView.type == .music) {
|
||||
MarqueeText(
|
||||
.constant(musicManager.songTitle),
|
||||
textColor: Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray,
|
||||
minDuration: 0.4,
|
||||
frameWidth: 100
|
||||
)
|
||||
.opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
|
||||
Spacer(minLength: vm.closedNotchSize.width)
|
||||
// Song Artist
|
||||
Text(musicManager.artistName)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.foregroundStyle(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor) : Color.gray)
|
||||
.opacity((coordinator.expandingView.show && coordinator.expandingView.type == .music && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0)
|
||||
}
|
||||
}
|
||||
)
|
||||
.frame(width: (coordinator.expandingView.show && coordinator.expandingView.type == .music && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 380 : vm.closedNotchSize.width + (isHovering ? 8 : 0))
|
||||
|
||||
|
||||
HStack {
|
||||
if useMusicVisualizer {
|
||||
Rectangle()
|
||||
.fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient)
|
||||
.frame(width: 50, alignment: .center)
|
||||
.matchedGeometryEffect(id: "spectrum", in: albumArtNamespace)
|
||||
.mask {
|
||||
AudioSpectrumView(isPlaying: $musicManager.isPlaying)
|
||||
.frame(width: 16, height: 12)
|
||||
}
|
||||
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
|
||||
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
|
||||
} else {
|
||||
LottieAnimationView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2),
|
||||
height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), alignment: .center)
|
||||
}
|
||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var dragDetector: some View {
|
||||
if Defaults[.dynamicShelf] {
|
||||
Color.clear
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.onDrop(of: [.data], isTargeted: $vm.dragDetectorTargeting) { _ in true }
|
||||
.onChange(of: vm.anyDropZoneTargeting) { _, isTargeted in
|
||||
if isTargeted, vm.notchState == .closed {
|
||||
coordinator.currentView = .shelf
|
||||
doOpen()
|
||||
} else if !isTargeted {
|
||||
print("DROP EVENT", vm.dropEvent)
|
||||
if vm.dropEvent {
|
||||
vm.dropEvent = false
|
||||
return
|
||||
}
|
||||
|
||||
vm.dropEvent = false
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private func doOpen() {
|
||||
withAnimation(.bouncy.speed(1.2)) {
|
||||
vm.open()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hover Management
|
||||
|
||||
/// Handle hover state changes with debouncing
|
||||
private func handleHover(_ hovering: Bool) {
|
||||
// Don't process events if we're already transitioning
|
||||
if isHoverStateChanging { return }
|
||||
|
||||
// Cancel any pending tasks
|
||||
hoverWorkItem?.cancel()
|
||||
hoverWorkItem = nil
|
||||
debounceWorkItem?.cancel()
|
||||
debounceWorkItem = nil
|
||||
|
||||
if hovering {
|
||||
// Handle mouse enter
|
||||
withAnimation(.bouncy.speed(1.2)) {
|
||||
isHovering = true
|
||||
}
|
||||
|
||||
// Only provide haptic feedback if notch is closed
|
||||
if vm.notchState == .closed && Defaults[.enableHaptics] {
|
||||
haptics.toggle()
|
||||
}
|
||||
|
||||
// Don't open notch if there's a sneak peek showing
|
||||
if coordinator.sneakPeek.show {
|
||||
return
|
||||
}
|
||||
|
||||
// Delay opening the notch
|
||||
let task = DispatchWorkItem {
|
||||
// ContentView is a struct, so we don't use weak self here
|
||||
guard vm.notchState == .closed, isHovering else { return }
|
||||
doOpen()
|
||||
}
|
||||
|
||||
hoverWorkItem = task
|
||||
DispatchQueue.main.asyncAfter(
|
||||
deadline: .now() + Defaults[.minimumHoverDuration],
|
||||
execute: task
|
||||
)
|
||||
} else {
|
||||
// Handle mouse exit with debounce to prevent flickering
|
||||
let debounce = DispatchWorkItem {
|
||||
// ContentView is a struct, so we don't use weak self here
|
||||
|
||||
// Update visual state
|
||||
withAnimation(.bouncy.speed(1.2)) {
|
||||
isHovering = false
|
||||
}
|
||||
|
||||
// Close the notch if it's open and battery popover is not active
|
||||
if vm.notchState == .open && !vm.isBatteryPopoverActive {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
|
||||
debounceWorkItem = debounce
|
||||
// Add a small delay to debounce rapid mouse movements
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: debounce)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gesture Handling
|
||||
|
||||
private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) {
|
||||
guard vm.notchState == .closed else { return }
|
||||
|
||||
withAnimation(.smooth) {
|
||||
gestureProgress = (translation / Defaults[.gestureSensitivity]) * 20
|
||||
}
|
||||
|
||||
if phase == .ended {
|
||||
withAnimation(.smooth) {
|
||||
gestureProgress = .zero
|
||||
}
|
||||
}
|
||||
|
||||
if translation > Defaults[.gestureSensitivity] {
|
||||
if Defaults[.enableHaptics] {
|
||||
haptics.toggle()
|
||||
}
|
||||
withAnimation(.smooth) {
|
||||
gestureProgress = .zero
|
||||
}
|
||||
doOpen()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleUpGesture(translation: CGFloat, phase: NSEvent.Phase) {
|
||||
if vm.notchState == .open && !vm.isHoveringCalendar {
|
||||
withAnimation(.smooth) {
|
||||
gestureProgress = (translation / Defaults[.gestureSensitivity]) * -20
|
||||
}
|
||||
|
||||
if phase == .ended {
|
||||
withAnimation(.smooth) {
|
||||
gestureProgress = .zero
|
||||
}
|
||||
}
|
||||
|
||||
if translation > Defaults[.gestureSensitivity] {
|
||||
withAnimation(.smooth) {
|
||||
gestureProgress = .zero
|
||||
isHovering = false
|
||||
}
|
||||
vm.close()
|
||||
|
||||
if Defaults[.enableHaptics] {
|
||||
haptics.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
struct FullScreenDropDelegate: DropDelegate {
|
||||
@Binding var isTargeted: Bool
|
||||
let onDrop: () -> Void
|
||||
|
||||
func dropEntered(info _: DropInfo) {
|
||||
isTargeted = true
|
||||
}
|
||||
|
||||
func dropExited(info _: DropInfo) {
|
||||
isTargeted = false
|
||||
}
|
||||
|
||||
func performDrop(info _: DropInfo) -> Bool {
|
||||
isTargeted = false
|
||||
onDrop()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.temporary-exception.apple-events</key>
|
||||
<array>
|
||||
<string>com.spotify.client</string>
|
||||
<string>com.apple.Music</string>
|
||||
</array>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
|
|
|
|||
|
|
@ -2,16 +2,398 @@
|
|||
// DynamicIslandApp.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by admin63 on 01/11/24.
|
||||
// Created by Harsh Vardhan Goswami on 02/08/24.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import KeyboardShortcuts
|
||||
import Sparkle
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
@main
|
||||
struct DynamicIslandApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
struct DynamicNotchApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@Default(.menubarIcon) var showMenuBarIcon
|
||||
@Environment(\.openWindow) var openWindow
|
||||
let updaterController: SPUStandardUpdaterController
|
||||
|
||||
init() {
|
||||
updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("dynamic.island", systemImage: "sparkle", isInserted: $showMenuBarIcon) {
|
||||
SettingsLink(label: {
|
||||
Text("Settings")
|
||||
})
|
||||
.keyboardShortcut(KeyEquivalent(","), modifiers: .command)
|
||||
if false {
|
||||
Button("Activate License") {
|
||||
openWindow(id: "activation")
|
||||
}
|
||||
}
|
||||
CheckForUpdatesView(updater: updaterController.updater)
|
||||
Divider()
|
||||
Button("Restart Dynamic Island") {
|
||||
guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return }
|
||||
|
||||
let workspace = NSWorkspace.shared
|
||||
|
||||
if let appURL = workspace.urlForApplication(withBundleIdentifier: bundleIdentifier) {
|
||||
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
configuration.createsNewApplicationInstance = true
|
||||
|
||||
workspace.openApplication(at: appURL, configuration: configuration)
|
||||
}
|
||||
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
Button("Quit", role: .destructive) {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
.keyboardShortcut(KeyEquivalent("Q"), modifiers: .command)
|
||||
}
|
||||
|
||||
Settings {
|
||||
SettingsView(updaterController: updaterController)
|
||||
}
|
||||
.defaultSize(CGSize(width: 750, height: 700))
|
||||
|
||||
Window("Onboarding", id: "onboarding") {
|
||||
ProOnboard()
|
||||
}
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentSize)
|
||||
|
||||
Window("Activation", id: "activation") {
|
||||
ActivationWindow()
|
||||
}
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowResizability(.contentSize)
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
var statusItem: NSStatusItem?
|
||||
var windows: [NSScreen: NSWindow] = [:]
|
||||
var viewModels: [NSScreen: DynamicIslandViewModel] = [:]
|
||||
var window: NSWindow?
|
||||
let vm: DynamicIslandViewModel = .init()
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
var whatsNewWindow: NSWindow?
|
||||
var timer: Timer?
|
||||
let calenderManager = CalendarManager()
|
||||
var closeNotchWorkItem: DispatchWorkItem?
|
||||
private var previousScreens: [NSScreen]?
|
||||
@Environment(\.openWindow) var openWindow
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
@objc func onScreenLocked(_: Notification) {
|
||||
print("Screen locked")
|
||||
cleanupWindows()
|
||||
}
|
||||
|
||||
@objc func onScreenUnlocked(_: Notification) {
|
||||
print("Screen unlocked")
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||
self?.cleanupWindows()
|
||||
self?.adjustWindowPosition(changeAlpha: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupWindows(shouldInvert: Bool = false) {
|
||||
if shouldInvert ? !Defaults[.showOnAllDisplays] : Defaults[.showOnAllDisplays] {
|
||||
for window in windows.values {
|
||||
window.close()
|
||||
NotchSpaceManager.shared.notchSpace.windows.remove(window)
|
||||
}
|
||||
windows.removeAll()
|
||||
viewModels.removeAll()
|
||||
} else if let window = window {
|
||||
window.close()
|
||||
NotchSpaceManager.shared.notchSpace.windows.remove(window)
|
||||
self.window = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func createDynamicIslandWindow(for screen: NSScreen, with viewModel: DynamicIslandViewModel) -> NSWindow {
|
||||
let window = DynamicIslandWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: openNotchSize.width, height: openNotchSize.height),
|
||||
styleMask: [.borderless, .nonactivatingPanel, .utilityWindow, .hudWindow],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
window.contentView = NSHostingView(
|
||||
rootView: ContentView()
|
||||
.environmentObject(viewModel)
|
||||
)
|
||||
|
||||
window.orderFrontRegardless()
|
||||
NotchSpaceManager.shared.notchSpace.windows.insert(window)
|
||||
return window
|
||||
}
|
||||
|
||||
private func positionWindow(_ window: NSWindow, on screen: NSScreen, changeAlpha: Bool = false) {
|
||||
if changeAlpha {
|
||||
window.alphaValue = 0
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak window] in
|
||||
guard let window = window else { return }
|
||||
let screenFrame = screen.frame
|
||||
window.setFrameOrigin(NSPoint(
|
||||
x: screenFrame.origin.x + (screenFrame.width / 2) - window.frame.width / 2,
|
||||
y: screenFrame.origin.y + screenFrame.height - window.frame.height
|
||||
))
|
||||
window.alphaValue = 1
|
||||
}
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
|
||||
coordinator.setupWorkersNotificationObservers();
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(screenConfigurationDidChange),
|
||||
name: NSApplication.didChangeScreenParametersNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
NotificationCenter.default.addObserver(forName: Notification.Name.selectedScreenChanged, object: nil, queue: nil) { [weak self] _ in
|
||||
self?.adjustWindowPosition(changeAlpha: true)
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: Notification.Name.notchHeightChanged, object: nil, queue: nil) { [weak self] _ in
|
||||
self?.adjustWindowPosition()
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: Notification.Name.automaticallySwitchDisplayChanged, object: nil, queue: nil) { [weak self] _ in
|
||||
guard let self = self, let window = self.window else { return }
|
||||
window.alphaValue = self.coordinator.selectedScreen == self.coordinator.preferredScreen ? 1 : 0
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: Notification.Name.showOnAllDisplaysChanged, object: nil, queue: nil) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.cleanupWindows(shouldInvert: true)
|
||||
|
||||
if(!Defaults[.showOnAllDisplays]) {
|
||||
let viewModel = self.vm
|
||||
let window = self.createDynamicIslandWindow(for: NSScreen.main ?? NSScreen.screens.first!, with: viewModel)
|
||||
self.window = window
|
||||
self.adjustWindowPosition(changeAlpha: true)
|
||||
} else {
|
||||
self.adjustWindowPosition()
|
||||
}
|
||||
}
|
||||
|
||||
DistributedNotificationCenter.default().addObserver(self, selector: #selector(onScreenLocked(_:)), name: NSNotification.Name(rawValue: "com.apple.screenIsLocked"), object: nil)
|
||||
DistributedNotificationCenter.default().addObserver(self, selector: #selector(onScreenUnlocked(_:)), name: NSNotification.Name(rawValue: "com.apple.screenIsUnlocked"), object: nil)
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .toggleSneakPeek) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.coordinator.toggleSneakPeek(
|
||||
status: !self.coordinator.sneakPeek.show,
|
||||
type: .music,
|
||||
duration: 3.0
|
||||
)
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .toggleNotchOpen) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
|
||||
var viewModel = self.vm
|
||||
|
||||
if(Defaults[.showOnAllDisplays]) {
|
||||
for screen in NSScreen.screens {
|
||||
if screen.frame.contains(mouseLocation) {
|
||||
if let screenViewModel = self.viewModels[screen] {
|
||||
viewModel = screenViewModel
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.closeNotchWorkItem?.cancel()
|
||||
self.closeNotchWorkItem = nil
|
||||
|
||||
switch viewModel.notchState {
|
||||
case .closed:
|
||||
viewModel.open()
|
||||
|
||||
let workItem = DispatchWorkItem { [weak viewModel] in
|
||||
viewModel?.close()
|
||||
}
|
||||
self.closeNotchWorkItem = workItem
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: workItem)
|
||||
case .open:
|
||||
viewModel.close()
|
||||
}
|
||||
}
|
||||
|
||||
if !Defaults[.showOnAllDisplays] {
|
||||
let viewModel = self.vm
|
||||
let window = createDynamicIslandWindow(for: NSScreen.main ?? NSScreen.screens.first!, with: viewModel)
|
||||
self.window = window
|
||||
adjustWindowPosition(changeAlpha: true)
|
||||
} else {
|
||||
adjustWindowPosition(changeAlpha: true)
|
||||
}
|
||||
|
||||
if coordinator.firstLaunch {
|
||||
DispatchQueue.main.async {
|
||||
self.openWindow(id: "onboarding")
|
||||
}
|
||||
playWelcomeSound()
|
||||
}
|
||||
|
||||
previousScreens = NSScreen.screens
|
||||
}
|
||||
|
||||
func playWelcomeSound() {
|
||||
let audioPlayer = AudioPlayer()
|
||||
audioPlayer.play(fileName: "dynamic", fileExtension: "m4a")
|
||||
}
|
||||
|
||||
func deviceHasNotch() -> Bool {
|
||||
if #available(macOS 12.0, *) {
|
||||
for screen in NSScreen.screens {
|
||||
if screen.safeAreaInsets.top > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@objc func screenConfigurationDidChange() {
|
||||
let currentScreens = NSScreen.screens
|
||||
|
||||
let screensChanged = currentScreens.count != previousScreens?.count ||
|
||||
Set(currentScreens.map { $0.localizedName }) != Set(previousScreens?.map { $0.localizedName } ?? []) ||
|
||||
Set(currentScreens.map { $0.frame }) != Set(previousScreens?.map { $0.frame } ?? [])
|
||||
|
||||
previousScreens = currentScreens
|
||||
|
||||
if screensChanged {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.cleanupWindows()
|
||||
self?.adjustWindowPosition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func adjustWindowPosition(changeAlpha: Bool = false) {
|
||||
if Defaults[.showOnAllDisplays] {
|
||||
let currentScreens = Set(NSScreen.screens)
|
||||
|
||||
for screen in windows.keys where !currentScreens.contains(screen) {
|
||||
if let window = windows[screen] {
|
||||
window.close()
|
||||
NotchSpaceManager.shared.notchSpace.windows.remove(window)
|
||||
windows.removeValue(forKey: screen)
|
||||
viewModels.removeValue(forKey: screen)
|
||||
}
|
||||
}
|
||||
|
||||
for screen in currentScreens {
|
||||
if windows[screen] == nil {
|
||||
let viewModel = DynamicIslandViewModel(screen: screen.localizedName)
|
||||
let window = createDynamicIslandWindow(for: screen, with: viewModel)
|
||||
|
||||
windows[screen] = window
|
||||
viewModels[screen] = viewModel
|
||||
}
|
||||
|
||||
if let window = windows[screen], let viewModel = viewModels[screen] {
|
||||
positionWindow(window, on: screen, changeAlpha: changeAlpha)
|
||||
|
||||
if viewModel.notchState == .closed {
|
||||
viewModel.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let selectedScreen: NSScreen
|
||||
|
||||
if let preferredScreen = NSScreen.screens.first(where: { $0.localizedName == coordinator.preferredScreen }) {
|
||||
coordinator.selectedScreen = coordinator.preferredScreen
|
||||
selectedScreen = preferredScreen
|
||||
} else if Defaults[.automaticallySwitchDisplay], let mainScreen = NSScreen.main {
|
||||
coordinator.selectedScreen = mainScreen.localizedName
|
||||
selectedScreen = mainScreen
|
||||
} else {
|
||||
if let window = window {
|
||||
window.alphaValue = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
vm.screen = selectedScreen.localizedName
|
||||
vm.notchSize = getClosedNotchSize(screen: selectedScreen.localizedName)
|
||||
|
||||
if window == nil {
|
||||
window = createDynamicIslandWindow(for: selectedScreen, with: vm)
|
||||
}
|
||||
|
||||
if let window = window {
|
||||
positionWindow(window, on: selectedScreen, changeAlpha: changeAlpha)
|
||||
|
||||
if vm.notchState == .closed {
|
||||
vm.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func togglePopover(_ sender: Any?) {
|
||||
if window?.isVisible == true {
|
||||
window?.orderOut(nil)
|
||||
} else {
|
||||
window?.orderFrontRegardless()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func showMenu() {
|
||||
statusItem?.menu?.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil)
|
||||
}
|
||||
|
||||
@objc func quitAction() {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let selectedScreenChanged = Notification.Name("SelectedScreenChanged")
|
||||
static let notchHeightChanged = Notification.Name("NotchHeightChanged")
|
||||
static let showOnAllDisplaysChanged = Notification.Name("showOnAllDisplaysChanged")
|
||||
static let automaticallySwitchDisplayChanged = Notification.Name("automaticallySwitchDisplayChanged")
|
||||
}
|
||||
|
||||
extension CGRect: @retroactive Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(origin.x)
|
||||
hasher.combine(origin.y)
|
||||
hasher.combine(size.width)
|
||||
hasher.combine(size.height)
|
||||
}
|
||||
|
||||
public static func == (lhs: CGRect, rhs: CGRect) -> Bool {
|
||||
return lhs.origin == rhs.origin && lhs.size == rhs.size
|
||||
}
|
||||
}
|
||||
|
|
|
|||
214
DynamicIsland/DynamicIslandViewCoordinator.swift
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
//
|
||||
// DynamicIslandViewCoordinator.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by Alexander on 2024-11-20.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
import TheBoringWorkerNotifier
|
||||
|
||||
enum SneakContentType {
|
||||
case brightness
|
||||
case volume
|
||||
case backlight
|
||||
case music
|
||||
case mic
|
||||
case battery
|
||||
case download
|
||||
}
|
||||
|
||||
struct sneakPeek {
|
||||
var show: Bool = false
|
||||
var type: SneakContentType = .music
|
||||
var value: CGFloat = 0
|
||||
var icon: String = ""
|
||||
}
|
||||
|
||||
struct SharedSneakPeek: Codable {
|
||||
var show: Bool
|
||||
var type: String
|
||||
var value: String
|
||||
var icon: String
|
||||
}
|
||||
|
||||
enum BrowserType {
|
||||
case chromium
|
||||
case safari
|
||||
}
|
||||
|
||||
struct ExpandedItem {
|
||||
var show: Bool = false
|
||||
var type: SneakContentType = .battery
|
||||
var value: CGFloat = 0
|
||||
var browser: BrowserType = .chromium
|
||||
}
|
||||
|
||||
class DynamicIslandViewCoordinator: ObservableObject {
|
||||
static let shared = DynamicIslandViewCoordinator()
|
||||
var notifier: TheBoringWorkerNotifier = .init()
|
||||
|
||||
@Published var currentView: NotchViews = .home
|
||||
private var sneakPeekDispatch: DispatchWorkItem?
|
||||
private var expandingViewDispatch: DispatchWorkItem?
|
||||
|
||||
|
||||
@AppStorage("firstLaunch") var firstLaunch: Bool = true
|
||||
@AppStorage("showWhatsNew") var showWhatsNew: Bool = true
|
||||
@AppStorage("musicLiveActivityEnabled") var musicLiveActivityEnabled: Bool = true
|
||||
@AppStorage("currentMicStatus") var currentMicStatus: Bool = true
|
||||
|
||||
@AppStorage("alwaysShowTabs") var alwaysShowTabs: Bool = true {
|
||||
didSet {
|
||||
if !alwaysShowTabs {
|
||||
openLastTabByDefault = false
|
||||
if TrayDrop.shared.isEmpty || !Defaults[.openShelfByDefault] {
|
||||
currentView = .home
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AppStorage("openLastTabByDefault") var openLastTabByDefault: Bool = false {
|
||||
didSet {
|
||||
if openLastTabByDefault {
|
||||
alwaysShowTabs = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AppStorage("hudReplacement") var hudReplacement: Bool = true {
|
||||
didSet {
|
||||
notifier.postNotification(name: notifier.toggleHudReplacementNotification.name, userInfo: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@AppStorage("preferred_screen_name") var preferredScreen = NSScreen.main?.localizedName ?? "Unknown" {
|
||||
didSet {
|
||||
selectedScreen = preferredScreen
|
||||
NotificationCenter.default.post(name: Notification.Name.selectedScreenChanged, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Published var selectedScreen: String = NSScreen.main?.localizedName ?? "Unknown"
|
||||
|
||||
@Published var optionKeyPressed: Bool = true
|
||||
|
||||
private init() {
|
||||
selectedScreen = preferredScreen
|
||||
notifier = TheBoringWorkerNotifier()
|
||||
}
|
||||
|
||||
func setupWorkersNotificationObservers() {
|
||||
notifier.setupObserver(notification: notifier.micStatusNotification, handler: initialMicStatus)
|
||||
notifier.setupObserver(notification: notifier.sneakPeakNotification, handler: sneakPeekEvent)
|
||||
}
|
||||
|
||||
@objc func sneakPeekEvent(_ notification: Notification) {
|
||||
let decoder = JSONDecoder()
|
||||
if let decodedData = try? decoder.decode(SharedSneakPeek.self, from: notification.userInfo?.first?.value as! Data) {
|
||||
let contentType = decodedData.type == "brightness" ? SneakContentType.brightness : decodedData.type == "volume" ? SneakContentType.volume : decodedData.type == "backlight" ? SneakContentType.backlight : decodedData.type == "mic" ? SneakContentType.mic : SneakContentType.brightness
|
||||
|
||||
let value = CGFloat((NumberFormatter().number(from: decodedData.value) ?? 0.0).floatValue)
|
||||
let icon = decodedData.icon
|
||||
|
||||
print(decodedData)
|
||||
|
||||
toggleSneakPeek(status: decodedData.show, type: contentType, value: value, icon: icon)
|
||||
|
||||
} else {
|
||||
print("Failed to decode JSON data")
|
||||
}
|
||||
}
|
||||
|
||||
func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") {
|
||||
sneakPeekDuration = duration
|
||||
if type != .music {
|
||||
// close()
|
||||
if !hudReplacement {
|
||||
return
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(.smooth) {
|
||||
self.sneakPeek.show = status
|
||||
self.sneakPeek.type = type
|
||||
self.sneakPeek.value = value
|
||||
self.sneakPeek.icon = icon
|
||||
}
|
||||
}
|
||||
|
||||
if type == .mic {
|
||||
currentMicStatus = value == 1
|
||||
}
|
||||
}
|
||||
|
||||
private var sneakPeekDuration: TimeInterval = 1.5
|
||||
|
||||
@Published var sneakPeek: sneakPeek = .init() {
|
||||
didSet {
|
||||
if sneakPeek.show {
|
||||
sneakPeekDispatch?.cancel()
|
||||
|
||||
sneakPeekDispatch = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
withAnimation {
|
||||
self.toggleSneakPeek(status: false, type: SneakContentType.music)
|
||||
self.sneakPeekDuration = 1.5
|
||||
}
|
||||
}
|
||||
DispatchQueue.main
|
||||
.asyncAfter(
|
||||
deadline: .now() + self.sneakPeekDuration,
|
||||
execute: sneakPeekDispatch!
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleExpandingView(status: Bool, type: SneakContentType, value: CGFloat = 0, browser: BrowserType = .chromium) {
|
||||
if expandingView.show {
|
||||
withAnimation(.smooth) {
|
||||
self.expandingView.show = false
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(.smooth) {
|
||||
self.expandingView.show = status
|
||||
self.expandingView.type = type
|
||||
self.expandingView.value = value
|
||||
self.expandingView.browser = browser
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var expandingView: ExpandedItem = .init() {
|
||||
didSet {
|
||||
if expandingView.show {
|
||||
expandingViewDispatch?.cancel()
|
||||
|
||||
expandingViewDispatch = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.toggleExpandingView(status: false, type: SneakContentType.battery)
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + (expandingView.type == .download ? 2 : 3), execute: expandingViewDispatch!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func initialMicStatus(_ notification: Notification) {
|
||||
currentMicStatus = notification.userInfo?.first?.value as! Bool
|
||||
}
|
||||
|
||||
func toggleMic() {
|
||||
notifier.postNotification(name: notifier.toggleMicNotification.name, userInfo: nil)
|
||||
}
|
||||
|
||||
func showEmpty() {
|
||||
currentView = .home
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Dynamic Island uses AppleScripts to control Spotify and Apple Music</string>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>Show calender events inside the notch</string>
|
||||
<key>SUEnableDownloaderService</key>
|
||||
|
|
@ -16,7 +18,7 @@
|
|||
<key>SUFeedURL</key>
|
||||
<string>https://TheBoredTeam.github.io/boring.notch/appcast.xml</string>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>LlPWpDWaN5DLDCuB45z9xwxVXkdIqJhOT0eP5CiMsto=</string>
|
||||
<string>B1Y47t8C/v8ImurYA+9arEsuCrpxwJSviekiflMElbI=</string>
|
||||
<key>UTImportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict/>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
"strings" : {
|
||||
" – %lld" : {
|
||||
|
||||
},
|
||||
"'Now Playing' was the only option on previous versions and works with all media apps." : {
|
||||
|
||||
},
|
||||
"(%@)" : {
|
||||
"localizations" : {
|
||||
|
|
@ -40,9 +43,6 @@
|
|||
},
|
||||
"• New feature 1" : {
|
||||
|
||||
},
|
||||
"⚠️ Important" : {
|
||||
|
||||
},
|
||||
"About" : {
|
||||
"localizations" : {
|
||||
|
|
@ -63,6 +63,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Access Denied" : {
|
||||
|
||||
},
|
||||
"Activate" : {
|
||||
|
||||
|
|
@ -72,9 +75,15 @@
|
|||
},
|
||||
"Activation" : {
|
||||
|
||||
},
|
||||
"Add" : {
|
||||
|
||||
},
|
||||
"Add manually" : {
|
||||
|
||||
},
|
||||
"Add new visualizer" : {
|
||||
|
||||
},
|
||||
"Additional features" : {
|
||||
|
||||
|
|
@ -87,6 +96,9 @@
|
|||
},
|
||||
"All-day" : {
|
||||
|
||||
},
|
||||
"Always hide in fullscreen" : {
|
||||
|
||||
},
|
||||
"Always show tabs" : {
|
||||
|
||||
|
|
@ -119,6 +131,15 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Battery Information" : {
|
||||
|
||||
},
|
||||
"Battery Settings" : {
|
||||
|
||||
},
|
||||
"Battery Status" : {
|
||||
|
||||
},
|
||||
"Boost your productivity with Clipboard Manager" : {
|
||||
|
||||
|
|
@ -164,6 +185,15 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Buy us a coffee" : {
|
||||
|
||||
},
|
||||
"Calendar" : {
|
||||
|
||||
},
|
||||
"Calendar access is denied. Please enable it in System Settings." : {
|
||||
|
||||
},
|
||||
"Cancel" : {
|
||||
|
||||
|
|
@ -177,6 +207,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Charging on Hold: Desktop Mode" : {
|
||||
|
||||
},
|
||||
"Check for updates" : {
|
||||
"extractionState" : "stale",
|
||||
|
|
@ -207,9 +240,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CPU" : {
|
||||
|
||||
},
|
||||
"Custom color" : {
|
||||
"localizations" : {
|
||||
|
|
@ -223,9 +253,15 @@
|
|||
},
|
||||
"Custom height" : {
|
||||
|
||||
},
|
||||
"Custom music live activity animation" : {
|
||||
|
||||
},
|
||||
"Custom notch size - %.0f" : {
|
||||
|
||||
},
|
||||
"Custom vizualizers (Lottie)" : {
|
||||
|
||||
},
|
||||
"Default" : {
|
||||
|
||||
|
|
@ -280,9 +316,6 @@
|
|||
},
|
||||
"Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" : {
|
||||
|
||||
},
|
||||
"Edit" : {
|
||||
|
||||
},
|
||||
"Edit layout" : {
|
||||
|
||||
|
|
@ -317,6 +350,9 @@
|
|||
},
|
||||
"Enable music live activity" : {
|
||||
|
||||
},
|
||||
"Enable sneak peek" : {
|
||||
|
||||
},
|
||||
"Enhance your experience with HUDs" : {
|
||||
|
||||
|
|
@ -326,9 +362,6 @@
|
|||
},
|
||||
"Error" : {
|
||||
|
||||
},
|
||||
"ERROR: VIEW NOT DEFINED" : {
|
||||
|
||||
},
|
||||
"Exclude apps" : {
|
||||
"localizations" : {
|
||||
|
|
@ -345,9 +378,6 @@
|
|||
},
|
||||
"Extensions" : {
|
||||
|
||||
},
|
||||
"Fullscreen media playback detection" : {
|
||||
|
||||
},
|
||||
"General" : {
|
||||
"localizations" : {
|
||||
|
|
@ -383,6 +413,12 @@
|
|||
},
|
||||
"Gradient" : {
|
||||
|
||||
},
|
||||
"Hide BoringNotch Options" : {
|
||||
|
||||
},
|
||||
"Hide only when NowPlaying app is in fullscreen" : {
|
||||
|
||||
},
|
||||
"Hierarchical" : {
|
||||
|
||||
|
|
@ -390,10 +426,7 @@
|
|||
"High" : {
|
||||
|
||||
},
|
||||
"Hover over the notch after changing the height to see the effect." : {
|
||||
|
||||
},
|
||||
"Hover over the notch after changing the screen to adapt the new size" : {
|
||||
"https://github.com/th-ch/youtube-music" : {
|
||||
|
||||
},
|
||||
"HUD style" : {
|
||||
|
|
@ -427,9 +460,15 @@
|
|||
},
|
||||
"License key" : {
|
||||
|
||||
},
|
||||
"Lottie JSON URL" : {
|
||||
|
||||
},
|
||||
"Low" : {
|
||||
|
||||
},
|
||||
"Low Power Mode" : {
|
||||
|
||||
},
|
||||
"Made with 🫶🏻 by not so boring not.people" : {
|
||||
"localizations" : {
|
||||
|
|
@ -446,6 +485,9 @@
|
|||
},
|
||||
"Match real notch size" : {
|
||||
|
||||
},
|
||||
"Max Capacity: %lld%%" : {
|
||||
|
||||
},
|
||||
"Media" : {
|
||||
"localizations" : {
|
||||
|
|
@ -480,10 +522,10 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Medium" : {
|
||||
"Media Source" : {
|
||||
|
||||
},
|
||||
"Memory" : {
|
||||
"Medium" : {
|
||||
|
||||
},
|
||||
"Menubar icon" : {
|
||||
|
|
@ -511,14 +553,26 @@
|
|||
},
|
||||
"More" : {
|
||||
|
||||
},
|
||||
"Music Source" : {
|
||||
|
||||
},
|
||||
"muted" : {
|
||||
|
||||
},
|
||||
"Name" : {
|
||||
|
||||
},
|
||||
"Need Restart" : {
|
||||
|
||||
},
|
||||
"Network" : {
|
||||
"Never hide" : {
|
||||
|
||||
},
|
||||
"No custom animation available" : {
|
||||
|
||||
},
|
||||
"No custom visualizer" : {
|
||||
|
||||
},
|
||||
"No events today" : {
|
||||
|
|
@ -541,14 +595,17 @@
|
|||
"No extension installed" : {
|
||||
|
||||
},
|
||||
"Non-notch displays" : {
|
||||
|
||||
},
|
||||
"Non-notch screen height" : {
|
||||
"Non-notch display height" : {
|
||||
|
||||
},
|
||||
"Notch behavior" : {
|
||||
|
||||
},
|
||||
"Notch display height" : {
|
||||
|
||||
},
|
||||
"Notch Height" : {
|
||||
|
||||
},
|
||||
"OK" : {
|
||||
|
||||
|
|
@ -578,6 +635,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Open System Settings" : {
|
||||
|
||||
},
|
||||
"Percentage" : {
|
||||
"localizations" : {
|
||||
|
|
@ -588,6 +648,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Plugged In" : {
|
||||
|
||||
},
|
||||
"Progress bar" : {
|
||||
"localizations" : {
|
||||
|
|
@ -658,6 +721,15 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select Calendars" : {
|
||||
|
||||
},
|
||||
"selected" : {
|
||||
|
||||
},
|
||||
"Selected animation" : {
|
||||
|
||||
},
|
||||
"Settings" : {
|
||||
"localizations" : {
|
||||
|
|
@ -685,6 +757,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show calendar" : {
|
||||
|
||||
},
|
||||
"Show charging indicator" : {
|
||||
"extractionState" : "stale",
|
||||
|
|
@ -721,12 +796,18 @@
|
|||
},
|
||||
"Show on a specific display" : {
|
||||
|
||||
},
|
||||
"Show on all displays" : {
|
||||
|
||||
},
|
||||
"Slider color" : {
|
||||
|
||||
},
|
||||
"Sneak Peek shows the media title and artist under the notch for a few seconds." : {
|
||||
|
||||
},
|
||||
"Sneak Peek Style" : {
|
||||
|
||||
},
|
||||
"Software updates" : {
|
||||
"localizations" : {
|
||||
|
|
@ -737,6 +818,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Speed" : {
|
||||
|
||||
},
|
||||
"Square" : {
|
||||
|
||||
|
|
@ -758,14 +842,13 @@
|
|||
|
||||
},
|
||||
"TheBoringNotch" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "TheDynamicIsland"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
"Time to Full Charge: %lld min" : {
|
||||
|
||||
},
|
||||
"Toggle Notch Open:" : {
|
||||
|
||||
},
|
||||
"Toggle Sneak Peek:" : {
|
||||
|
||||
|
|
@ -787,6 +870,9 @@
|
|||
},
|
||||
"Upgrade to Pro" : {
|
||||
|
||||
},
|
||||
"Use music visualizer spectrogram" : {
|
||||
|
||||
},
|
||||
"Version" : {
|
||||
"localizations" : {
|
||||
|
|
@ -816,6 +902,9 @@
|
|||
},
|
||||
"What's New" : {
|
||||
|
||||
},
|
||||
"YouTube Music requires this third-party app to be installed: " : {
|
||||
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
|
|
|
|||
154
DynamicIsland/MediaControllers/AppleMusicController.swift
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
//
|
||||
// AppleMusicController.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander Greco on 2025-03-29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
class AppleMusicController: MediaControllerProtocol {
|
||||
// MARK: - Properties
|
||||
@Published private var playbackState: PlaybackState = PlaybackState(
|
||||
bundleIdentifier: "com.apple.Music"
|
||||
)
|
||||
|
||||
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
|
||||
|
||||
// MARK: - Initialization
|
||||
init() {
|
||||
setupPlaybackStateChangeObserver()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if self?.isActive() == true {
|
||||
self?.updatePlaybackInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPlaybackStateChangeObserver() {
|
||||
DistributedNotificationCenter.default().addObserver(
|
||||
self,
|
||||
selector: #selector(updatePlaybackInfo),
|
||||
name: NSNotification.Name("com.apple.Music.playerInfo"),
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
DistributedNotificationCenter.default().removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name("com.apple.Music.playerInfo"),
|
||||
object: nil
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Protocol Implementation
|
||||
func play() {
|
||||
executeCommand("play")
|
||||
}
|
||||
|
||||
func pause() {
|
||||
executeCommand("pause")
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
executeCommand("playpause")
|
||||
}
|
||||
|
||||
func nextTrack() {
|
||||
executeCommand("next track")
|
||||
}
|
||||
|
||||
func previousTrack() {
|
||||
executeCommand("previous track")
|
||||
}
|
||||
|
||||
func seek(to time: Double) {
|
||||
executeCommand("set player position to \(time)")
|
||||
updatePlaybackInfo()
|
||||
}
|
||||
|
||||
func isActive() -> Bool {
|
||||
let runningApps = NSWorkspace.shared.runningApplications
|
||||
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
@objc func updatePlaybackInfo() {
|
||||
guard let descriptor = fetchPlaybackInfo() else { return }
|
||||
guard descriptor.numberOfItems >= 8 else { return }
|
||||
|
||||
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
|
||||
let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown"
|
||||
let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
|
||||
let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown"
|
||||
let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0
|
||||
let duration = descriptor.atIndex(6)?.doubleValue ?? 0
|
||||
let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
|
||||
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
|
||||
let artworkData = descriptor.atIndex(9)?.data as Data?
|
||||
|
||||
let updatedState = PlaybackState(
|
||||
bundleIdentifier: "com.apple.Music",
|
||||
isPlaying: isPlaying,
|
||||
title: currentTrack,
|
||||
artist: currentTrackArtist,
|
||||
album: currentTrackAlbum,
|
||||
currentTime: currentTime,
|
||||
duration: duration,
|
||||
playbackRate: 1,
|
||||
isShuffled: isShuffled,
|
||||
isRepeating: isRepeating,
|
||||
lastUpdated: Date(),
|
||||
artwork: (artworkData?.isEmpty ?? true) ? nil : artworkData
|
||||
)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.playbackState = updatedState
|
||||
}
|
||||
}
|
||||
|
||||
private func executeCommand(_ command: String) {
|
||||
let script = "tell application \"Music\" to \(command)"
|
||||
Task {
|
||||
try? await AppleScriptHelper.executeVoid(script)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchPlaybackInfo() -> NSAppleEventDescriptor? {
|
||||
let script = """
|
||||
tell application "Music"
|
||||
set isRunning to true
|
||||
try
|
||||
set playerState to player state is playing
|
||||
set currentTrackName to name of current track
|
||||
set currentTrackArtist to artist of current track
|
||||
set currentTrackAlbum to album of current track
|
||||
set trackPosition to player position
|
||||
set trackDuration to duration of current track
|
||||
set shuffleState to false
|
||||
set repeatState to false
|
||||
try
|
||||
set artData to data of artwork 1 of current track
|
||||
on error
|
||||
set artData to ""
|
||||
end try
|
||||
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artData}
|
||||
on error
|
||||
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, false, ""}
|
||||
end try
|
||||
end tell
|
||||
"""
|
||||
var descriptor: NSAppleEventDescriptor? = nil
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
Task {
|
||||
descriptor = try? await AppleScriptHelper.execute(script)
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
21
DynamicIsland/MediaControllers/MediaControllerProtocol.swift
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
//
|
||||
// MediaControllerProtocol.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander Greco on 2025-03-29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
protocol MediaControllerProtocol: ObservableObject {
|
||||
var playbackStatePublisher: Published<PlaybackState>.Publisher { get }
|
||||
func play()
|
||||
func pause()
|
||||
func seek(to time: Double)
|
||||
func nextTrack()
|
||||
func previousTrack()
|
||||
func togglePlay()
|
||||
func isActive() -> Bool
|
||||
func updatePlaybackInfo()
|
||||
}
|
||||
197
DynamicIsland/MediaControllers/NowPlayingController.swift
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
//
|
||||
// NowPlayingController.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander on 2025-03-29.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
class NowPlayingController: ObservableObject, MediaControllerProtocol {
|
||||
// MARK: - Properties
|
||||
@Published var playbackState: PlaybackState = .init(
|
||||
bundleIdentifier: "com.apple.Music"
|
||||
)
|
||||
|
||||
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var lastMusicItem: (title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)?
|
||||
|
||||
// MARK: - Media Remote Functions
|
||||
private let mediaRemoteBundle: CFBundle
|
||||
private let MRMediaRemoteGetNowPlayingInfo: @convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void
|
||||
private let MRMediaRemoteRegisterForNowPlayingNotifications: @convention(c) (DispatchQueue) -> Void
|
||||
private let MRMediaRemoteGetNowPlayingApplicationIsPlaying: @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void
|
||||
private let MRMediaRemoteGetNowPlayingClient: @convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void
|
||||
private let MRNowPlayingClientGetBundleIdentifier: @convention(c) (AnyObject?) -> String?
|
||||
private let MRNowPlayingClientGetParentAppBundleIdentifier: @convention(c) (AnyObject?) -> String?
|
||||
private let MRMediaRemoteSendCommandFunction:@convention(c) (Int, AnyObject?) -> Void
|
||||
private let MRMediaRemoteSetElapsedTimeFunction: @convention(c) (Double) -> Void
|
||||
|
||||
// MARK: - Initialization
|
||||
init?() {
|
||||
guard let bundle = CFBundleCreate(kCFAllocatorDefault, NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")),
|
||||
let MRMediaRemoteGetNowPlayingInfoPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingInfo" as CFString),
|
||||
let MRMediaRemoteRegisterForNowPlayingNotificationsPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteRegisterForNowPlayingNotifications" as CFString),
|
||||
let MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying" as CFString),
|
||||
let MRMediaRemoteGetNowPlayingClientPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingClient" as CFString),
|
||||
let MRNowPlayingClientGetBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetBundleIdentifier" as CFString),
|
||||
let MRNowPlayingClientGetParentAppBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetParentAppBundleIdentifier" as CFString),
|
||||
let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSendCommand" as CFString),
|
||||
let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSetElapsedTime" as CFString)
|
||||
else { return nil }
|
||||
|
||||
mediaRemoteBundle = bundle
|
||||
MRMediaRemoteGetNowPlayingInfo = unsafeBitCast(MRMediaRemoteGetNowPlayingInfoPointer, to: (@convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void).self)
|
||||
MRMediaRemoteRegisterForNowPlayingNotifications = unsafeBitCast(MRMediaRemoteRegisterForNowPlayingNotificationsPointer, to: (@convention(c) (DispatchQueue) -> Void).self)
|
||||
MRMediaRemoteGetNowPlayingApplicationIsPlaying = unsafeBitCast(MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer, to: (@convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void).self)
|
||||
MRMediaRemoteGetNowPlayingClient = unsafeBitCast(MRMediaRemoteGetNowPlayingClientPointer, to: (@convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void).self)
|
||||
MRNowPlayingClientGetBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self)
|
||||
MRNowPlayingClientGetParentAppBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetParentAppBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self)
|
||||
MRMediaRemoteSendCommandFunction = unsafeBitCast(MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self)
|
||||
MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast(MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self)
|
||||
|
||||
setupNowPlayingObserver()
|
||||
updatePlaybackInfo()
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Clean up all Combine subscribers
|
||||
cancellables.removeAll()
|
||||
|
||||
// Remove distributed notification observers
|
||||
DistributedNotificationCenter.default().removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
|
||||
object: nil
|
||||
)
|
||||
|
||||
DistributedNotificationCenter.default().removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name("com.apple.Music.playerInfo"),
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Protocol Implementation
|
||||
func play() {
|
||||
MRMediaRemoteSendCommandFunction(0, nil)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
MRMediaRemoteSendCommandFunction(1, nil)
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
MRMediaRemoteSendCommandFunction(2, nil)
|
||||
}
|
||||
|
||||
func nextTrack() {
|
||||
MRMediaRemoteSendCommandFunction(4, nil)
|
||||
}
|
||||
|
||||
func previousTrack() {
|
||||
MRMediaRemoteSendCommandFunction(5, nil)
|
||||
}
|
||||
|
||||
func seek(to time: Double) {
|
||||
MRMediaRemoteSetElapsedTimeFunction(time)
|
||||
}
|
||||
|
||||
func isActive() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Setup Methods
|
||||
private func setupNowPlayingObserver() {
|
||||
MRMediaRemoteRegisterForNowPlayingNotifications(DispatchQueue.main)
|
||||
|
||||
NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingInfoDidChangeNotification"))
|
||||
.sink { [weak self] _ in self?.updatePlaybackInfo() }
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingApplicationDidChangeNotification"))
|
||||
.sink { [weak self] _ in self?.updateApp() }
|
||||
.store(in: &cancellables)
|
||||
|
||||
DistributedNotificationCenter.default().addObserver(
|
||||
self,
|
||||
selector: #selector(updatePlaybackInfo),
|
||||
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
|
||||
object: nil
|
||||
)
|
||||
|
||||
DistributedNotificationCenter.default().addObserver(
|
||||
self,
|
||||
selector: #selector(updatePlaybackInfo),
|
||||
name: NSNotification.Name("com.apple.Music.playerInfo"),
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Update Methods
|
||||
@objc func updateApp() {
|
||||
// Get the now playing client
|
||||
MRMediaRemoteGetNowPlayingClient(DispatchQueue.main) { [weak self] clientObj in
|
||||
guard let clientObj = clientObj else {
|
||||
DispatchQueue.main.async {
|
||||
self?.playbackState.bundleIdentifier = "com.apple.Music" // Default fallback
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Try to get parent bundle ID first, then fall back to direct bundle ID
|
||||
var appBundleID = self?.MRNowPlayingClientGetParentAppBundleIdentifier(clientObj)
|
||||
if appBundleID == nil {
|
||||
appBundleID = self?.MRNowPlayingClientGetBundleIdentifier(clientObj)
|
||||
}
|
||||
|
||||
// Special case for WebKit.GPU which is often Safari
|
||||
if appBundleID == "com.apple.WebKit.GPU" {
|
||||
appBundleID = "com.apple.Safari"
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self?.playbackState.bundleIdentifier = appBundleID ?? "com.apple.Music"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func updatePlaybackInfo() {
|
||||
updateApp()
|
||||
|
||||
MRMediaRemoteGetNowPlayingInfo(DispatchQueue.main) { [weak self] information in
|
||||
guard let self = self else { return }
|
||||
|
||||
let title = information["kMRMediaRemoteNowPlayingInfoTitle"] as? String ?? ""
|
||||
let artist = information["kMRMediaRemoteNowPlayingInfoArtist"] as? String ?? ""
|
||||
let album = information["kMRMediaRemoteNowPlayingInfoAlbum"] as? String ?? ""
|
||||
let duration = information["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval ?? 0
|
||||
let currentTime = information["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? TimeInterval ?? 0
|
||||
let artworkData = information["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data
|
||||
let timestamp = information["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date ?? Date()
|
||||
let playbackRate = information["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 1
|
||||
|
||||
// Update playback state
|
||||
DispatchQueue.main.async {
|
||||
self.playbackState.title = title
|
||||
self.playbackState.artist = artist
|
||||
self.playbackState.album = album
|
||||
self.playbackState.duration = duration
|
||||
self.playbackState.currentTime = currentTime
|
||||
self.playbackState.artwork = artworkData
|
||||
self.playbackState.lastUpdated = timestamp
|
||||
self.playbackState.playbackRate = playbackRate
|
||||
|
||||
// Check playback state
|
||||
self.MRMediaRemoteGetNowPlayingApplicationIsPlaying(DispatchQueue.main) { [weak self] isPlaying in
|
||||
DispatchQueue.main.async {
|
||||
self?.playbackState.isPlaying = isPlaying
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
168
DynamicIsland/MediaControllers/SpotifyController.swift
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
//
|
||||
// SpotifyController.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Alexander Greco on 2025-03-29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
class SpotifyController: MediaControllerProtocol {
|
||||
// MARK: - Properties
|
||||
@Published private var playbackState: PlaybackState = PlaybackState(
|
||||
bundleIdentifier: "com.spotify.client"
|
||||
)
|
||||
|
||||
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
|
||||
|
||||
init() {
|
||||
setupPlaybackStateChangeObserver()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if self?.isActive() == true {
|
||||
self?.updatePlaybackInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPlaybackStateChangeObserver() {
|
||||
DistributedNotificationCenter.default().addObserver(
|
||||
self,
|
||||
selector: #selector(updatePlaybackInfo),
|
||||
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Remove notification observer when controller is deallocated
|
||||
DistributedNotificationCenter.default().removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Protocol Implementation
|
||||
func play() {
|
||||
executeCommand("play")
|
||||
}
|
||||
|
||||
func pause() {
|
||||
executeCommand("pause")
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
executeCommand("playpause")
|
||||
}
|
||||
|
||||
func nextTrack() {
|
||||
executeCommand("next track")
|
||||
}
|
||||
|
||||
func previousTrack() {
|
||||
executeCommand("previous track")
|
||||
}
|
||||
|
||||
func seek(to time: Double) {
|
||||
executeCommand("set player position to \(time)")
|
||||
updatePlaybackInfo()
|
||||
}
|
||||
|
||||
func isActive() -> Bool {
|
||||
let runningApps = NSWorkspace.shared.runningApplications
|
||||
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
@objc func updatePlaybackInfo() {
|
||||
guard let descriptor = fetchPlaybackInfo() else { return }
|
||||
guard descriptor.numberOfItems >= 9 else { return }
|
||||
|
||||
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
|
||||
let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown"
|
||||
let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
|
||||
let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown"
|
||||
let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0
|
||||
let duration = (descriptor.atIndex(6)?.doubleValue ?? 0)/1000
|
||||
let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
|
||||
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
|
||||
let artworkURL = descriptor.atIndex(9)?.stringValue ?? ""
|
||||
|
||||
let state = PlaybackState(
|
||||
bundleIdentifier: "com.spotify.client",
|
||||
isPlaying: isPlaying,
|
||||
title: currentTrack,
|
||||
artist: currentTrackArtist,
|
||||
album: currentTrackAlbum,
|
||||
currentTime: currentTime,
|
||||
duration: duration,
|
||||
playbackRate: 1,
|
||||
isShuffled: isShuffled,
|
||||
isRepeating: isRepeating,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.playbackState = state
|
||||
}
|
||||
|
||||
// Load artwork asynchronously and update the state when complete
|
||||
if !artworkURL.isEmpty, let url = URL(string: artworkURL) {
|
||||
DispatchQueue.global(qos: .background).async { [weak self] in
|
||||
do {
|
||||
let artworkData = try Data(contentsOf: url)
|
||||
DispatchQueue.main.async {
|
||||
// Create a new state with the artwork data and update
|
||||
var updatedState = state
|
||||
updatedState.artwork = artworkData
|
||||
self?.playbackState = updatedState
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load artwork: \(error)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.playbackState = state
|
||||
}
|
||||
}
|
||||
|
||||
private func executeCommand(_ command: String) {
|
||||
let script = "tell application \"Spotify\" to \(command)"
|
||||
Task {
|
||||
try? await AppleScriptHelper.executeVoid(script)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchPlaybackInfo() -> NSAppleEventDescriptor? {
|
||||
let script = """
|
||||
tell application "Spotify"
|
||||
set isRunning to true
|
||||
try
|
||||
set playerState to player state is playing
|
||||
set currentTrackName to name of current track
|
||||
set currentTrackArtist to artist of current track
|
||||
set currentTrackAlbum to album of current track
|
||||
set trackPosition to player position
|
||||
set trackDuration to duration of current track
|
||||
set shuffleState to shuffling
|
||||
set repeatState to repeating
|
||||
set artworkURL to artwork url of current track
|
||||
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL}
|
||||
on error
|
||||
return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, ""}
|
||||
end try
|
||||
end tell
|
||||
"""
|
||||
|
||||
var descriptor: NSAppleEventDescriptor? = nil
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
Task {
|
||||
descriptor = try? await AppleScriptHelper.execute(script)
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
386
DynamicIsland/MediaControllers/YouTubeMusicController.swift
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
//
|
||||
// YouTubeMusicController.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created By Alexander Greco on 2025-03-30.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
class YouTubeMusicController: MediaControllerProtocol {
|
||||
// MARK: - Properties
|
||||
@Published var playbackState: PlaybackState = .init(
|
||||
bundleIdentifier: "com.github.th-ch.youtube-music",
|
||||
isPlaying: true
|
||||
)
|
||||
|
||||
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
|
||||
private let baseURL = "http://localhost:26538"
|
||||
private var accessToken: String?
|
||||
private var refreshTimer: Timer?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var playbackInfoCancellable: AnyCancellable?
|
||||
private var isAuthenticating = false
|
||||
private let authQueue = DispatchQueue(label: "com.boringnotch.youtubemusicauth", qos: .background)
|
||||
|
||||
init() {
|
||||
setupAppStateObservers()
|
||||
authQueue.async { [weak self] in
|
||||
self?.authenticateAndSetup()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopPeriodicUpdates()
|
||||
cancellables.forEach { $0.cancel() }
|
||||
NSWorkspace.shared.notificationCenter.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: - Authentication
|
||||
|
||||
private func authenticateAndSetup() {
|
||||
// Prevent multiple concurrent authentication attempts
|
||||
guard !isAuthenticating else { return }
|
||||
|
||||
isAuthenticating = true
|
||||
getAccessToken { [weak self] success in
|
||||
guard let self = self else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.isAuthenticating = false
|
||||
if success {
|
||||
self.startPeriodicUpdates()
|
||||
self.updatePlaybackInfo()
|
||||
} else {
|
||||
// Retry authentication after a delay if it fails
|
||||
DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 5) { [weak self] in
|
||||
self?.authenticateAndSetup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getAccessToken(completion: @escaping (Bool) -> Void) {
|
||||
guard let url = URL(string: "\(baseURL)/auth/boringNotch") else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
|
||||
URLSession.shared.dataTaskPublisher(for: request)
|
||||
.map { data, response -> Data in
|
||||
return data
|
||||
}
|
||||
.decode(type: AuthResponse.self, decoder: JSONDecoder())
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveCompletion: { completionStatus in
|
||||
switch completionStatus {
|
||||
case .failure(let error):
|
||||
completion(false)
|
||||
case .finished:
|
||||
break
|
||||
}
|
||||
}, receiveValue: { [weak self] response in
|
||||
self?.accessToken = response.accessToken
|
||||
completion(true)
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Protocol Implementation
|
||||
|
||||
func play() {
|
||||
sendCommand(endpoint: "/play", method: "POST")
|
||||
updatePlaybackInfo()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
sendCommand(endpoint: "/pause", method: "POST")
|
||||
updatePlaybackInfo()
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
if !isActive() {
|
||||
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: playbackState.bundleIdentifier) {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
sendCommand(endpoint: "/toggle-play", method: "POST")
|
||||
updatePlaybackInfo()
|
||||
}
|
||||
|
||||
func nextTrack() {
|
||||
sendCommand(endpoint: "/next", method: "POST")
|
||||
updatePlaybackInfo()
|
||||
}
|
||||
|
||||
func previousTrack() {
|
||||
sendCommand(endpoint: "/previous", method: "POST")
|
||||
updatePlaybackInfo()
|
||||
}
|
||||
|
||||
func seek(to time: Double) {
|
||||
guard let url = URL(string: "\(baseURL)/seek-to") else { return }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
if let token = accessToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
} else {
|
||||
authenticateAndSetup()
|
||||
return
|
||||
}
|
||||
|
||||
// Create the request body with the seconds value
|
||||
let seekData = ["seconds": time]
|
||||
|
||||
do {
|
||||
request.httpBody = try JSONEncoder().encode(seekData)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
URLSession.shared.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response -> Data in
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
|
||||
throw URLError(.userAuthenticationRequired)
|
||||
} else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
if case .failure(let error) = completion {
|
||||
if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired {
|
||||
self?.accessToken = nil
|
||||
self?.authenticateAndSetup()
|
||||
}
|
||||
}
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.updatePlaybackInfo()
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func isActive() -> Bool {
|
||||
let runningApps = NSWorkspace.shared.runningApplications
|
||||
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func setupAppStateObservers() {
|
||||
// Register for app launch notifications
|
||||
NSWorkspace.shared.notificationCenter.addObserver(
|
||||
self,
|
||||
selector: #selector(handleAppStateChange),
|
||||
name: NSWorkspace.didLaunchApplicationNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
// Register for app termination notifications
|
||||
NSWorkspace.shared.notificationCenter.addObserver(
|
||||
self,
|
||||
selector: #selector(handleAppStateChange),
|
||||
name: NSWorkspace.didTerminateApplicationNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func handleAppStateChange(notification: Notification) {
|
||||
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
||||
app.bundleIdentifier == playbackState.bundleIdentifier else {
|
||||
return
|
||||
}
|
||||
|
||||
if notification.name == NSWorkspace.didLaunchApplicationNotification {
|
||||
startPeriodicUpdates()
|
||||
} else if notification.name == NSWorkspace.didTerminateApplicationNotification {
|
||||
// App has terminated - update state and stop timer
|
||||
stopPeriodicUpdates()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.playbackState = PlaybackState(
|
||||
bundleIdentifier: self.playbackState.bundleIdentifier,
|
||||
isPlaying: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPeriodicUpdates() {
|
||||
stopPeriodicUpdates() // Ensure we don't create multiple timers
|
||||
|
||||
// Only start the timer if the app is actually running
|
||||
guard isActive() else { return }
|
||||
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
|
||||
self?.updatePlaybackInfo()
|
||||
}
|
||||
refreshTimer?.tolerance = 0.5
|
||||
|
||||
// Initial update when timer starts
|
||||
updatePlaybackInfo()
|
||||
}
|
||||
|
||||
private func stopPeriodicUpdates() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
}
|
||||
|
||||
func updatePlaybackInfo() {
|
||||
if !isActive() {
|
||||
playbackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier, isPlaying: false)
|
||||
stopPeriodicUpdates()
|
||||
return
|
||||
}
|
||||
guard let request = createAuthenticatedRequest(for: "/api/v1/song") else { return }
|
||||
|
||||
let backgroundQueue = DispatchQueue.global(qos: .utility)
|
||||
|
||||
backgroundQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
playbackInfoCancellable?.cancel()
|
||||
|
||||
playbackInfoCancellable = URLSession.shared.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response -> Data in
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
// Check for authentication errors
|
||||
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
|
||||
// Re-authenticate and retry later
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.accessToken = nil
|
||||
self?.authenticateAndSetup()
|
||||
}
|
||||
throw URLError(.userAuthenticationRequired)
|
||||
} else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
.decode(type: PlaybackResponse.self, decoder: JSONDecoder())
|
||||
.subscribe(on: backgroundQueue)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveCompletion: { _ in }, receiveValue: { [weak self] response in
|
||||
self?.updatePlaybackState(with: response)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func updatePlaybackState(with response: PlaybackResponse) {
|
||||
playbackState.isPlaying = !response.isPaused
|
||||
playbackState.title = response.title
|
||||
playbackState.artist = response.artist
|
||||
playbackState.album = response.album ?? ""
|
||||
playbackState.currentTime = response.elapsedSeconds
|
||||
playbackState.duration = response.songDuration
|
||||
playbackState.lastUpdated = Date()
|
||||
|
||||
// Load artwork if available
|
||||
if let artworkURL = response.imageSrc, let url = URL(string: artworkURL) {
|
||||
DispatchQueue.global(qos: .background).async { [weak self] in
|
||||
do {
|
||||
let artworkData = try Data(contentsOf: url)
|
||||
DispatchQueue.main.async {
|
||||
self?.playbackState.artwork = artworkData
|
||||
}
|
||||
} catch { return }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendCommand(endpoint: String, method: String = "GET") {
|
||||
guard let url = URL(string: "\(baseURL)/api/v1\(endpoint)") else { return }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
|
||||
if let token = accessToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
} else {
|
||||
authenticateAndSetup()
|
||||
return
|
||||
}
|
||||
|
||||
URLSession.shared.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response -> Data in
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
|
||||
throw URLError(.userAuthenticationRequired)
|
||||
} else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveCompletion: { [weak self] completion in
|
||||
if case .failure(let error) = completion {
|
||||
if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired {
|
||||
// Authentication error - token might be expired
|
||||
self?.accessToken = nil
|
||||
self?.authenticateAndSetup()
|
||||
|
||||
// Try the command again after authentication
|
||||
DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||
self?.sendCommand(endpoint: endpoint, method: method)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, receiveValue: { [weak self] _ in
|
||||
self?.updatePlaybackInfo()
|
||||
})
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func createAuthenticatedRequest(for endpoint: String) -> URLRequest? {
|
||||
guard let token = accessToken, let url = URL(string: "\(baseURL)\(endpoint)") else {
|
||||
if accessToken == nil {
|
||||
// Token is missing, try to authenticate again
|
||||
authenticateAndSetup()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - API Response Models
|
||||
|
||||
struct AuthResponse: Codable {
|
||||
let accessToken: String
|
||||
}
|
||||
|
||||
struct PlaybackResponse: Codable {
|
||||
let isPaused: Bool
|
||||
let title: String
|
||||
let artist: String
|
||||
let album: String?
|
||||
let elapsedSeconds: Double
|
||||
let songDuration: Double
|
||||
let imageSrc: String?
|
||||
}
|
||||
18
DynamicIsland/Shortcuts/ShortcutConstants.swift
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
//
|
||||
// Constants.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 16/08/2024.
|
||||
//
|
||||
|
||||
import KeyboardShortcuts
|
||||
import SwiftUI
|
||||
|
||||
extension KeyboardShortcuts.Name {
|
||||
static let clipboardHistoryPanel = Self("clipboardHistoryPanel", default: .init(.c, modifiers: [.shift, .command]))
|
||||
static let toggleMicrophone = Self("toggleMicrophone", default: .init(.f5, modifiers: [.function]))
|
||||
static let decreaseBacklight = Self("decreaseBacklight", default: .init(.f1, modifiers: [.command]))
|
||||
static let increaseBacklight = Self("increaseBacklight", default: .init(.f2, modifiers: [.command]))
|
||||
static let toggleSneakPeek = Self("toggleSneakPeek", default: .init(.h, modifiers: [.command, .shift]))
|
||||
static let toggleNotchOpen = Self("toggleNotchOpen", default: .init(.i, modifiers: [.command, .shift]))
|
||||
}
|
||||
152
DynamicIsland/animations/HelloAnimation.swift
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
//
|
||||
// HelloAnimation.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 04/08/24.
|
||||
// HelloAnimation.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 08/08/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HelloShape: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let width = rect.size.width
|
||||
let height = rect.size.height
|
||||
path.move(to: CGPoint(x: 0.00095*width, y: 0.88718*height))
|
||||
path.addCurve(to: CGPoint(x: 0.19536*width, y: 0.31015*height), control1: CGPoint(x: 0.00993*width, y: 0.87738*height), control2: CGPoint(x: 0.16556*width, y: 0.56785*height))
|
||||
path.addCurve(to: CGPoint(x: 0.15043*width, y: 0.04964*height), control1: CGPoint(x: 0.22517*width, y: 0.05245*height), control2: CGPoint(x: 0.1859*width, y: -0.068*height))
|
||||
path.addCurve(to: CGPoint(x: 0.10028*width, y: 0.932*height), control1: CGPoint(x: 0.11495*width, y: 0.16729*height), control2: CGPoint(x: 0.09792*width, y: 1.02023*height))
|
||||
path.addCurve(to: CGPoint(x: 0.18354*width, y: 0.47822*height), control1: CGPoint(x: 0.10265*width, y: 0.84376*height), control2: CGPoint(x: 0.12157*width, y: 0.47822*height))
|
||||
path.addCurve(to: CGPoint(x: 0.22327*width, y: 0.88718*height), control1: CGPoint(x: 0.25733*width, y: 0.51463*height), control2: CGPoint(x: 0.19915*width, y: 0.81575*height))
|
||||
path.addCurve(to: CGPoint(x: 0.38553*width, y: 0.71351*height), control1: CGPoint(x: 0.2474*width, y: 0.95861*height), control2: CGPoint(x: 0.33586*width, y: 0.89978*height))
|
||||
path.addCurve(to: CGPoint(x: 0.35998*width, y: 0.45441*height), control1: CGPoint(x: 0.43519*width, y: 0.52724*height), control2: CGPoint(x: 0.38978*width, y: 0.4306*height))
|
||||
path.addCurve(to: CGPoint(x: 0.35478*width, y: 0.87317*height), control1: CGPoint(x: 0.33018*width, y: 0.47822*height), control2: CGPoint(x: 0.27956*width, y: 0.71631*height))
|
||||
path.addCurve(to: CGPoint(x: 0.53453*width, y: 0.62808*height), control1: CGPoint(x: 0.42999*width, y: 1.03004*height), control2: CGPoint(x: 0.51892*width, y: 0.6939*height))
|
||||
path.addCurve(to: CGPoint(x: 0.57332*width, y: 0.00623*height), control1: CGPoint(x: 0.55014*width, y: 0.56225*height), control2: CGPoint(x: 0.63955*width, y: 0.05805*height))
|
||||
path.addCurve(to: CGPoint(x: 0.48723*width, y: 0.60146*height), control1: CGPoint(x: 0.5071*width, y: -0.04559*height), control2: CGPoint(x: 0.48486*width, y: 0.50623*height))
|
||||
path.addCurve(to: CGPoint(x: 0.54588*width, y: 0.91239*height), control1: CGPoint(x: 0.48959*width, y: 0.6967*height), control2: CGPoint(x: 0.50378*width, y: 0.87597*height))
|
||||
path.addCurve(to: CGPoint(x: 0.70719*width, y: 0.51043*height), control1: CGPoint(x: 0.58798*width, y: 0.9488*height), control2: CGPoint(x: 0.6807*width, y: 0.64768*height))
|
||||
path.addCurve(to: CGPoint(x: 0.73273*width, y: 0.01323*height), control1: CGPoint(x: 0.73368*width, y: 0.37317*height), control2: CGPoint(x: 0.76679*width, y: 0.03984*height))
|
||||
path.addCurve(to: CGPoint(x: 0.67171*width, y: 0.15048*height), control1: CGPoint(x: 0.69868*width, y: -0.01338*height), control2: CGPoint(x: 0.68259*width, y: 0.07205*height))
|
||||
path.addCurve(to: CGPoint(x: 0.69678*width, y: 0.92639*height), control1: CGPoint(x: 0.66083*width, y: 0.22892*height), control2: CGPoint(x: 0.62204*width, y: 0.86057*height))
|
||||
path.addCurve(to: CGPoint(x: 0.87275*width, y: 0.47822*height), control1: CGPoint(x: 0.77152*width, y: 0.99222*height), control2: CGPoint(x: 0.78855*width, y: 0.42997*height))
|
||||
path.addCurve(to: CGPoint(x: 0.91438*width, y: 0.89776*height), control1: CGPoint(x: 0.9734*width, y: 0.51043*height), control2: CGPoint(x: 0.92329*width, y: 0.85998*height))
|
||||
path.addCurve(to: CGPoint(x: 0.79943*width, y: 0.69608*height), control1: CGPoint(x: 0.87047*width, y: 1.08403*height), control2: CGPoint(x: 0.77956*width, y: height))
|
||||
path.addCurve(to: CGPoint(x: 0.92006*width, y: 0.53081*height), control1: CGPoint(x: 0.81523*width, y: 0.45436*height), control2: CGPoint(x: 0.86282*width, y: 0.43277*height))
|
||||
path.addCurve(to: CGPoint(x: 0.99905*width, y: 0.432*height), control1: CGPoint(x: 0.95979*width, y: 0.57703*height), control2: CGPoint(x: 0.98959*width, y: 0.4944*height))
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
extension ShapeStyle where Self == AngularGradient {
|
||||
static var hello: some ShapeStyle {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .blue, location: 0.0),
|
||||
.init(color: .purple, location: 0.2),
|
||||
.init(color: .red, location: 0.4),
|
||||
.init(color: .mint, location: 0.5),
|
||||
.init(color: .indigo, location: 0.7),
|
||||
.init(color: .pink, location: 0.9),
|
||||
.init(color: .blue, location: 1.0)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct GlowingSnake<
|
||||
Content: Shape,
|
||||
Fill: ShapeStyle
|
||||
>: View, Animatable {
|
||||
|
||||
var progress: Double
|
||||
var delay: Double = 1.0
|
||||
var fill: Fill
|
||||
var lineWidth = 4.0
|
||||
var blurRadius = 8.0
|
||||
|
||||
@ViewBuilder var shape: Content
|
||||
|
||||
var animatableData: Double {
|
||||
get { progress }
|
||||
set { progress = newValue }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
shape
|
||||
.trim(
|
||||
from: {
|
||||
if progress > 1 - delay {
|
||||
2 * progress - 1.0
|
||||
} else if progress > delay {
|
||||
progress - delay
|
||||
} else {
|
||||
.zero
|
||||
}
|
||||
}(),
|
||||
to: progress
|
||||
)
|
||||
.glow(
|
||||
fill: fill,
|
||||
lineWidth: lineWidth,
|
||||
blurRadius: blurRadius
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct HelloAnimation: View {
|
||||
@State private var progress: Double = 0.0
|
||||
|
||||
var body: some View {
|
||||
GlowingSnake(
|
||||
progress: progress,
|
||||
fill: .hello,
|
||||
lineWidth: 8,
|
||||
blurRadius: 8.0,
|
||||
shape: { HelloShape() }
|
||||
)
|
||||
.onAppear {
|
||||
withAnimation(
|
||||
.easeInOut(duration: 4.0)
|
||||
) {
|
||||
progress = 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View where Self: Shape {
|
||||
func glow(
|
||||
fill: some ShapeStyle,
|
||||
lineWidth: Double,
|
||||
blurRadius: Double = 8.0,
|
||||
lineCap: CGLineCap = .round
|
||||
) -> some View {
|
||||
self
|
||||
.stroke(style: StrokeStyle(lineWidth: lineWidth / 2, lineCap: lineCap))
|
||||
.fill(fill)
|
||||
.overlay {
|
||||
self
|
||||
.stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: lineCap))
|
||||
.fill(fill)
|
||||
.blur(radius: blurRadius)
|
||||
}
|
||||
.overlay {
|
||||
self
|
||||
.stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: lineCap))
|
||||
.fill(fill)
|
||||
.blur(radius: blurRadius / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HelloAnimation()
|
||||
.frame(width: 300, height: 100)
|
||||
}
|
||||
29
DynamicIsland/animations/drop.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
//
|
||||
// drop.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 04/08/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
|
||||
public class DynamicIslandAnimations {
|
||||
@Published var notchStyle: Style = .notch
|
||||
|
||||
init() {
|
||||
self.notchStyle = .notch
|
||||
}
|
||||
|
||||
var animation: Animation {
|
||||
if #available(macOS 14.0, *), notchStyle == .notch {
|
||||
Animation.spring(.bouncy(duration: 0.4))
|
||||
} else {
|
||||
Animation.timingCurve(0.16, 1, 0.3, 1, duration: 0.7)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Move all animations to this file
|
||||
|
||||
}
|
||||
82
DynamicIsland/components/AnimatedFace.swift
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
//
|
||||
// AnimatedFace.swift
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 04/08/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MinimalFaceFeatures: View {
|
||||
@State private var isBlinking = false
|
||||
@State var height:CGFloat = 20;
|
||||
@State var width:CGFloat = 30;
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) { // Adjusted spacing to fit within 30x30
|
||||
// Eyes
|
||||
HStack(spacing: 4) { // Adjusted spacing to fit within 30x30
|
||||
Eye(isBlinking: $isBlinking)
|
||||
Eye(isBlinking: $isBlinking)
|
||||
}
|
||||
|
||||
// Nose and mouth combined
|
||||
VStack(spacing: 2) { // Adjusted spacing to fit within 30x30
|
||||
// Nose
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.white)
|
||||
.frame(width: 3, height: 4)
|
||||
|
||||
// Mouth (happy)
|
||||
GeometryReader { geometry in
|
||||
Path { path in
|
||||
let width = geometry.size.width
|
||||
let height = geometry.size.height
|
||||
path.move(to: CGPoint(x: 0, y: height / 2))
|
||||
path.addQuadCurve(to: CGPoint(x: width, y: height / 2), control: CGPoint(x: width / 2, y: height))
|
||||
}
|
||||
.stroke(Color.white, lineWidth: 2)
|
||||
}
|
||||
.frame(width: 14, height: 10)
|
||||
}
|
||||
}
|
||||
.frame(width: self.width, height: self.height) // Maximum size of face
|
||||
.onAppear {
|
||||
startBlinking()
|
||||
}
|
||||
}
|
||||
|
||||
func startBlinking() {
|
||||
Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
|
||||
withAnimation(.spring(duration: 0.2)) {
|
||||
isBlinking = true
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
withAnimation(.spring(duration: 0.2)) {
|
||||
isBlinking = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Eye: View {
|
||||
@Binding var isBlinking: Bool
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.white)
|
||||
.frame(width: 4, height: isBlinking ? 1 : 4)
|
||||
.frame(maxWidth: 15, maxHeight: 15) // Adjusted max size
|
||||
.animation(.easeInOut(duration: 0.1), value: isBlinking)
|
||||
}
|
||||
}
|
||||
|
||||
struct MinimalFaceFeatures_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ZStack {
|
||||
Color.black
|
||||
MinimalFaceFeatures()
|
||||
}
|
||||
.previewLayout(.fixed(width: 60, height: 60)) // Adjusted preview size for better visibility
|
||||
}
|
||||
}
|
||||
39
DynamicIsland/components/BottomRoundedRectangle.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
struct BottomRoundedRectangle: Shape {
|
||||
var radius: CGFloat
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
|
||||
// Top left corner
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
|
||||
// Top right corner
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||
|
||||
// Bottom right corner (rounded)
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - radius))
|
||||
path.addArc(center: CGPoint(x: rect.maxX - radius, y: rect.maxY - radius),
|
||||
radius: radius,
|
||||
startAngle: Angle(degrees: 0),
|
||||
endAngle: Angle(degrees: 90),
|
||||
clockwise: false)
|
||||
|
||||
// Bottom left corner (rounded)
|
||||
path.addLine(to: CGPoint(x: rect.minX + radius, y: rect.maxY))
|
||||
path.addArc(center: CGPoint(x: rect.minX + radius, y: rect.maxY - radius),
|
||||
radius: radius,
|
||||
startAngle: Angle(degrees: 90),
|
||||
endAngle: Angle(degrees: 180),
|
||||
clockwise: false)
|
||||
|
||||
// Back to top left to close the path
|
||||
path.closeSubpath()
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
315
DynamicIsland/components/Calender/BoringCalendar.swift
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
//
|
||||
// BoringCalender.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 08/09/24.
|
||||
//
|
||||
|
||||
import EventKit
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct Config: Equatable {
|
||||
// var count: Int = 10 // 3 days past + today + 7 days future
|
||||
var past: Int = 3
|
||||
var future: Int = 7
|
||||
var steps: Int = 1 // Each step is one day
|
||||
var spacing: CGFloat = 1
|
||||
var showsText: Bool = true
|
||||
var offset: Int = 2 // Number of dates to the left of the selected date
|
||||
}
|
||||
|
||||
struct WheelPicker: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@Binding var selectedDate: Date
|
||||
@State private var scrollPosition: Int?
|
||||
@State private var haptics: Bool = false
|
||||
@State private var byClick: Bool = false
|
||||
let config: Config
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: config.spacing) {
|
||||
let totalSteps = config.steps * (config.past + config.future)
|
||||
let spacerNum = config.offset
|
||||
ForEach(0..<totalSteps + 2 * spacerNum + 1, id: \.self) { index in
|
||||
if(index < spacerNum || index > totalSteps + spacerNum - 1){
|
||||
Spacer().frame(width: 24, height: 24).id(index)
|
||||
} else {
|
||||
let offset = -config.offset - config.past
|
||||
let date = dateForIndex(index, offset: offset)
|
||||
let isSelected = isDateSelected(index, offset: offset)
|
||||
dateButton(date: date, isSelected: isSelected, offset: offset){
|
||||
selectedDate = date
|
||||
byClick = true
|
||||
withAnimation{
|
||||
scrollPosition = indexForDate(date, offset: offset) - config.offset
|
||||
}
|
||||
//haptics.toggle() // Causes double haptic when click
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 50)
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.scrollPosition(id: $scrollPosition, anchor: .leading)
|
||||
.scrollTargetBehavior(.viewAligned) // Ensures scroll view snaps to button center
|
||||
.safeAreaPadding(.horizontal)
|
||||
.sensoryFeedback(.alignment, trigger: haptics)
|
||||
.onChange(of: scrollPosition) { oldValue, newValue in
|
||||
if(!byClick){
|
||||
handleScrollChange(newValue: newValue, config: config)
|
||||
}else{
|
||||
byClick = false
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
scrollToToday(config: config)
|
||||
}
|
||||
}
|
||||
|
||||
private func dateButton(date: Date, isSelected: Bool, offset: Int, onClick:@escaping()->Void) -> some View {
|
||||
Button(action: onClick) {
|
||||
VStack(spacing: 2) {
|
||||
dayText(date: dateToString(for: date), isSelected: isSelected)
|
||||
dateCircle(date: date, isSelected: isSelected)
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.id(indexForDate(date, offset: offset))
|
||||
}
|
||||
|
||||
private func dayText(date: String, isSelected: Bool) -> some View {
|
||||
Text(date)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(
|
||||
isSelected ? Defaults[.accentColor] : .gray
|
||||
)
|
||||
}
|
||||
|
||||
private func dateCircle(date: Date, isSelected: Bool) -> some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isSelected ? Defaults[.accentColor] : .clear)
|
||||
.frame(width: 24, height: 24)
|
||||
Text("\(date.date)")
|
||||
.font(.title3)
|
||||
.foregroundStyle(isSelected ? .white : .gray)
|
||||
}
|
||||
}
|
||||
|
||||
func handleScrollChange(newValue: Int?, config: Config) {
|
||||
let offset = -config.offset - config.past
|
||||
let todayIndex = indexForDate(Date(), offset: offset)
|
||||
guard let newIndex = newValue else { return }
|
||||
let targetDateIndex = newIndex + config.offset
|
||||
switch targetDateIndex{
|
||||
case todayIndex-config.past..<todayIndex+config.future:
|
||||
selectedDate = dateForIndex(targetDateIndex, offset: offset)
|
||||
haptics.toggle()
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToToday(config: Config) {
|
||||
let today = Date()
|
||||
let todayIndex = indexForDate(today, offset: -config.offset - config.past)
|
||||
byClick = true
|
||||
scrollPosition = todayIndex - config.offset
|
||||
selectedDate = today
|
||||
}
|
||||
|
||||
private func indexForDate(_ date: Date, offset: Int) -> Int {
|
||||
let calendar = Calendar.current
|
||||
let startDate = calendar.startOfDay(for: calendar.date(byAdding: .day, value: offset, to: Date()) ?? Date())
|
||||
let targetDate = calendar.startOfDay(for: date)
|
||||
let daysDifference = calendar.dateComponents([.day], from: startDate, to: targetDate).day ?? 0
|
||||
return daysDifference
|
||||
}
|
||||
|
||||
private func dateForIndex(_ index: Int, offset: Int) -> Date {
|
||||
let startDate = Calendar.current.date(byAdding: .day, value: offset, to: Date()) ?? Date()
|
||||
return Calendar.current.date(byAdding: .day, value: index, to: startDate) ?? Date()
|
||||
}
|
||||
|
||||
private func dayForIndex(_ index: Int, offset: Int) -> String {
|
||||
let date = dateForIndex(index, offset: offset)
|
||||
return dateToString(for: date)
|
||||
}
|
||||
|
||||
private func dateToString(for date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "E"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func isDateSelected(_ index: Int, offset: Int) -> Bool {
|
||||
Calendar.current.isDate(dateForIndex(index, offset: offset), inSameDayAs: selectedDate)
|
||||
}
|
||||
}
|
||||
|
||||
struct CalendarView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@StateObject private var calendarManager = CalendarManager()
|
||||
@State private var selectedDate = Date()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
HStack {
|
||||
Text("\(selectedDate, format: .dateTime.month())")
|
||||
.font(.system(size: 18))
|
||||
.fontWeight(.semibold)
|
||||
ZStack {
|
||||
WheelPicker(selectedDate: $selectedDate, config: Config())
|
||||
HStack {
|
||||
LinearGradient(
|
||||
colors: [.black, .clear], startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
.frame(width: 20)
|
||||
Spacer()
|
||||
LinearGradient(
|
||||
colors: [.clear, .black], startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
.frame(width: 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
if calendarManager.events.isEmpty {
|
||||
EmptyEventsView()
|
||||
} else {
|
||||
EventListView(events: calendarManager.events)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.onChange(of: selectedDate) { _, newDate in
|
||||
calendarManager.updateCurrentDate(newDate)
|
||||
}
|
||||
.onChange(of: vm.notchState) { _, _ in
|
||||
calendarManager.updateCurrentDate(Date.now)
|
||||
}
|
||||
.onAppear {
|
||||
calendarManager.updateCurrentDate(Date.now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyEventsView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
Text("No events today")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
Text("Enjoy your free time!")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EventListView: View {
|
||||
@Environment(\.openURL) private var openURL
|
||||
let events: [EKEvent]
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .trailing, spacing: 5) {
|
||||
ForEach(events.indices, id: \.self) { index in
|
||||
VStack(alignment: .trailing) {
|
||||
if isAllDayEvent(
|
||||
start: events[index].startDate, end: events[index].endDate)
|
||||
{
|
||||
Text("All-day")
|
||||
} else {
|
||||
Text("\(events[index].startDate, style: .time)")
|
||||
Text("\(events[index].endDate, style: .time)")
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.trailing)
|
||||
.padding(.bottom, 8)
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
ForEach(events.indices, id: \.self) { index in
|
||||
HStack(alignment: .top) {
|
||||
VStack(spacing: 5) {
|
||||
Image(
|
||||
systemName: isEventEnded(events[index].endDate)
|
||||
? "checkmark.circle" : "circle"
|
||||
)
|
||||
.foregroundColor(isEventEnded(events[index].endDate) ? .green : .gray)
|
||||
.font(.footnote)
|
||||
Rectangle()
|
||||
.frame(width: 1)
|
||||
.foregroundStyle(.gray.opacity(0.5))
|
||||
.opacity(index == events.count - 1 ? 0 : 1)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
|
||||
Button {
|
||||
if let url = generateEventURL(for: events[index]) {
|
||||
openURL(url)
|
||||
}
|
||||
} label: {
|
||||
Text(events[index].title)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.opacity(isEventEnded(events[index].endDate) ? 0.6 : 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
.scrollTargetBehavior(.viewAligned)
|
||||
}
|
||||
|
||||
private func isAllDayEvent(start: Date, end: Date) -> Bool {
|
||||
let calendar = Calendar.current
|
||||
|
||||
let startComponents = calendar.dateComponents([.hour, .minute], from: start)
|
||||
let endComponents = calendar.dateComponents([.hour, .minute], from: end)
|
||||
|
||||
return startComponents.hour == 0 && startComponents.minute == 0 && endComponents.hour == 23
|
||||
&& endComponents.minute == 59
|
||||
}
|
||||
|
||||
private func isEventEnded(_ end: Date) -> Bool {
|
||||
return Date.now > end
|
||||
}
|
||||
|
||||
private func generateEventURL(for event: EKEvent) -> URL? {
|
||||
var dateComponent = ""
|
||||
if event.hasRecurrenceRules {
|
||||
if let startDate = event.startDate {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd'T'HHmmss'Z'"
|
||||
formatter.timeZone = TimeZone.current
|
||||
if !event.isAllDay {
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
}
|
||||
dateComponent = "/\(formatter.string(from: startDate))"
|
||||
}
|
||||
}
|
||||
return URL(string: "ical://ekevent\(dateComponent)/\(event.calendarItemIdentifier)?method=show&options=more")
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the CalendarManager, EmptyEventsView, and EventListView as they were in the previous implementation
|
||||
|
||||
#Preview {
|
||||
CalendarView()
|
||||
.frame(width: 250)
|
||||
.padding(.horizontal)
|
||||
.background(.black)
|
||||
.environmentObject(DynamicIslandViewModel())
|
||||
}
|
||||
109
DynamicIsland/components/DynamicIslandSystemTiles.swift
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//
|
||||
// DynamicIslandSystemTiles.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 04/08/24.
|
||||
// BoringSystemTiles.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 16/08/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct SystemItemButton: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@State var icon: String = "gear"
|
||||
var onTap: () -> Void
|
||||
@State var label: String?
|
||||
@State var showEmojis: Bool = true
|
||||
@State var emoji: String = "🔧"
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
if Defaults[.tileShowLabels] {
|
||||
HStack {
|
||||
if !showEmojis {
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 10)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
|
||||
Text((showEmojis ? "\(emoji) " : "") + label!)
|
||||
.font(.caption2)
|
||||
.fontWeight(.regular)
|
||||
.foregroundStyle(.gray)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.allowsTightening(true)
|
||||
.minimumScaleFactor(0.7)
|
||||
.lineLimit(1)
|
||||
}
|
||||
} else {
|
||||
Color.clear
|
||||
.overlay {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
.buttonStyle(BouncingButtonStyle(vm: vm))
|
||||
}
|
||||
}
|
||||
|
||||
func logout() {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
let appleScript = """
|
||||
tell application "System Events" to log out
|
||||
"""
|
||||
|
||||
var error: NSDictionary?
|
||||
if let scriptObject = NSAppleScript(source: appleScript) {
|
||||
scriptObject.executeAndReturnError(&error)
|
||||
if let error = error {
|
||||
print("Error: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DynamicIslandSystemTiles: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
|
||||
struct ItemButton {
|
||||
var icon: String
|
||||
var onTap: () -> Void
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Grid {
|
||||
GridRow {
|
||||
// SystemItemButton(icon: "clipboard", onTap: {
|
||||
// vm.openClipboard()
|
||||
// }, label: "Clipboard History", showEmojis: Defaults[.showEmojis], emoji: "✨")
|
||||
// SystemItemButton(icon: "keyboard", onTap: {
|
||||
// vm?.close()
|
||||
// vm?.togglesneakPeek(status: true, type: .backlight, value: 1)
|
||||
// }, label: "💡 Keyboard Backlight")
|
||||
}
|
||||
GridRow {
|
||||
SystemItemButton(icon: coordinator.currentMicStatus ? "mic" : "mic.slash", onTap: {
|
||||
coordinator.toggleMic()
|
||||
vm.close()
|
||||
}, label: "Toggle Microphone", showEmojis: Defaults[.showEmojis], emoji: coordinator.currentMicStatus ? "😀" : "🤫")
|
||||
// SystemItemButton(icon: "lock", onTap: {
|
||||
// logout()
|
||||
// }, label: "🔒 Lock My Device")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DynamicIslandSystemTiles().padding()
|
||||
}
|
||||
26
DynamicIsland/components/EmptyState.swift
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
//
|
||||
// EmptyState.swift
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 04/08/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct EmptyStateView: View {
|
||||
var message: String
|
||||
@State private var isVisible = true
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
MinimalFaceFeatures(
|
||||
height: 70, width: 80)
|
||||
Text(message)
|
||||
.font(.system(size:14))
|
||||
.foregroundColor(.gray)
|
||||
}.transition(.blurReplace.animation(.spring(.bouncy(duration: 0.3)))) // Smooth animation
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
EmptyStateView(message: "Play some music babies")
|
||||
}
|
||||
46
DynamicIsland/components/HoverButton.swift
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//
|
||||
// HoverButton.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Kraigo on 04.09.2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct HoverButton: View {
|
||||
var icon: String
|
||||
var iconColor: Color = .white;
|
||||
var scale: Image.Scale = .medium
|
||||
var action: () -> Void
|
||||
var contentTransition: ContentTransition = .symbolEffect;
|
||||
|
||||
@State private var isHovering = false
|
||||
|
||||
var body: some View {
|
||||
let size = CGFloat(scale == .large ? 40 : 30)
|
||||
|
||||
Button(action: action) {
|
||||
Rectangle()
|
||||
.fill(.clear)
|
||||
.contentShape(Rectangle())
|
||||
.frame(width: size, height: size)
|
||||
.overlay {
|
||||
Capsule()
|
||||
.fill(isHovering ? Color.gray.opacity(0.2) : .clear)
|
||||
.frame(width: size, height: size)
|
||||
.overlay {
|
||||
Image(systemName: icon)
|
||||
.foregroundColor(iconColor)
|
||||
.contentTransition(contentTransition)
|
||||
.font(scale == .large ? .largeTitle : .body)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.onHover { hovering in
|
||||
withAnimation(.smooth(duration: 0.3)) {
|
||||
isHovering = hovering
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
DynamicIsland/components/Live activities/DownloadView.swift
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//
|
||||
// DownloadView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 17/08/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum Browser {
|
||||
case safari
|
||||
case chrome
|
||||
}
|
||||
|
||||
struct DownloadFile {
|
||||
var name: String
|
||||
var size: Int
|
||||
var formattedSize: String
|
||||
var browser: Browser
|
||||
}
|
||||
|
||||
class DownloadWatcher: ObservableObject {
|
||||
@Published var downloadFiles: [DownloadFile] = []
|
||||
}
|
||||
|
||||
struct DownloadArea: View {
|
||||
@EnvironmentObject var watcher: DownloadWatcher
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center) {
|
||||
HStack {
|
||||
if watcher.downloadFiles.first!.browser == .safari {
|
||||
AppIcon(for: "com.apple.safari")
|
||||
} else {
|
||||
Image(.chrome).resizable().scaledToFit().frame(width: 30, height: 30)
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Text("Download")
|
||||
Text("In progress").font(.system(.footnote)).foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .trailing) {
|
||||
Text(watcher.downloadFiles.first!.formattedSize)
|
||||
Text(watcher.downloadFiles.first!.name).font(.caption2).foregroundStyle(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
/// A view that displays the battery status with an icon and charging indicator.
|
||||
struct BatteryView: View {
|
||||
|
||||
var levelBattery: Float
|
||||
var isPluggedIn: Bool
|
||||
var isCharging: Bool
|
||||
var isInLowPowerMode: Bool
|
||||
var batteryWidth: CGFloat = 26
|
||||
var isForNotification: Bool
|
||||
|
||||
var animationStyle: BoringAnimations = BoringAnimations()
|
||||
|
||||
var icon: String = "battery.0"
|
||||
|
||||
/// Determines the icon to display when charging.
|
||||
var iconStatus: String {
|
||||
if isCharging {
|
||||
return "bolt"
|
||||
}
|
||||
else if isPluggedIn {
|
||||
return "plug"
|
||||
}
|
||||
else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines the color of the battery based on its status.
|
||||
var batteryColor: Color {
|
||||
if isInLowPowerMode {
|
||||
return .yellow
|
||||
} else if levelBattery <= 20 && !isCharging && !isPluggedIn {
|
||||
return .red
|
||||
} else if isCharging || isPluggedIn || levelBattery == 100 {
|
||||
return .green
|
||||
} else {
|
||||
return .white
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
|
||||
Image(systemName: icon)
|
||||
.resizable()
|
||||
.fontWeight(.thin)
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
.frame(
|
||||
width: batteryWidth + 1
|
||||
)
|
||||
|
||||
RoundedRectangle(cornerRadius: 2.5)
|
||||
.fill(batteryColor)
|
||||
.frame(
|
||||
width: CGFloat(((CGFloat(CFloat(levelBattery)) / 100) * (batteryWidth - 6))),
|
||||
height: (batteryWidth - 2.75) - 18
|
||||
)
|
||||
.padding(.leading, 2)
|
||||
|
||||
if iconStatus != "" && (isForNotification || Defaults[.showPowerStatusIcons]) {
|
||||
ZStack {
|
||||
Image(iconStatus)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.foregroundColor(.white)
|
||||
.frame(
|
||||
width: 17,
|
||||
height: 17
|
||||
)
|
||||
}
|
||||
.frame(width: batteryWidth, height: batteryWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A view that displays detailed battery information and settings.
|
||||
struct BatteryMenuView: View {
|
||||
|
||||
var isPluggedIn: Bool
|
||||
var isCharging: Bool
|
||||
var levelBattery: Float
|
||||
var maxCapacity: Float
|
||||
var timeToFullCharge: Int
|
||||
var isInLowPowerMode: Bool
|
||||
var onDismiss: () -> Void
|
||||
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
|
||||
HStack {
|
||||
Text("Battery Status")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
Spacer()
|
||||
Text("\(Int(levelBattery))%")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Max Capacity: \(Int(maxCapacity))%")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.regular)
|
||||
if isInLowPowerMode {
|
||||
Label("Low Power Mode", systemImage: "bolt.circle")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.regular)
|
||||
}
|
||||
if isCharging {
|
||||
Label("Charging", systemImage: "bolt.fill")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.regular)
|
||||
}
|
||||
if isPluggedIn {
|
||||
Label("Plugged In", systemImage: "powerplug.fill")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.regular)
|
||||
}
|
||||
if timeToFullCharge > 0 {
|
||||
Label("Time to Full Charge: \(timeToFullCharge) min", systemImage: "clock")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.regular)
|
||||
}
|
||||
if !isCharging && isPluggedIn && levelBattery >= 80 {
|
||||
Label("Charging on Hold: Desktop Mode", systemImage: "desktopcomputer")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.regular)
|
||||
}
|
||||
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Divider().background(Color.white)
|
||||
|
||||
Button(action: openBatteryPreferences) {
|
||||
Label("Battery Settings", systemImage: "gearshape")
|
||||
.fontWeight(.regular)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.padding()
|
||||
.frame(width: 280)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
private func openBatteryPreferences() {
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.battery") {
|
||||
openURL(url)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// A view that displays the battery status and allows interaction to show detailed information.
|
||||
struct DynamicIslandBatteryView: View {
|
||||
|
||||
@State var batteryWidth: CGFloat = 26
|
||||
var isCharging: Bool = false
|
||||
var isInLowPowerMode: Bool = false
|
||||
var isPluggedIn: Bool = false
|
||||
var levelBattery: Float = 0
|
||||
var maxCapacity: Float = 0
|
||||
var timeToFullCharge: Int = 0
|
||||
@State var isForNotification: Bool = false
|
||||
|
||||
@State private var showPopupMenu: Bool = false
|
||||
@State private var isPressed: Bool = false
|
||||
@State private var isHoveringPopover: Bool = false
|
||||
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if Defaults[.showBatteryPercentage] {
|
||||
Text("\(Int32(levelBattery))%")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
BatteryView(
|
||||
levelBattery: levelBattery,
|
||||
isPluggedIn: isPluggedIn,
|
||||
isCharging: isCharging,
|
||||
isInLowPowerMode: isInLowPowerMode,
|
||||
batteryWidth: batteryWidth,
|
||||
isForNotification: isForNotification
|
||||
)
|
||||
}
|
||||
.scaleEffect(isPressed ? 0.95 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.2), value: isPressed)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
withAnimation {
|
||||
isPressed = true
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation {
|
||||
isPressed = false
|
||||
showPopupMenu.toggle()
|
||||
}
|
||||
}
|
||||
)
|
||||
.popover(
|
||||
isPresented: $showPopupMenu,
|
||||
arrowEdge: .bottom) {
|
||||
BatteryMenuView(
|
||||
isPluggedIn: isPluggedIn,
|
||||
isCharging: isCharging,
|
||||
levelBattery: levelBattery,
|
||||
maxCapacity: maxCapacity,
|
||||
timeToFullCharge: timeToFullCharge,
|
||||
isInLowPowerMode: isInLowPowerMode,
|
||||
onDismiss: {
|
||||
showPopupMenu = false
|
||||
}
|
||||
)
|
||||
.onHover { hovering in
|
||||
isHoveringPopover = hovering
|
||||
}
|
||||
}
|
||||
.onChange(of: showPopupMenu) { _, _ in
|
||||
updateBatteryPopoverActiveState()
|
||||
}
|
||||
.onChange(of: isHoveringPopover) { _, _ in
|
||||
updateBatteryPopoverActiveState()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateBatteryPopoverActiveState() {
|
||||
vm.isBatteryPopoverActive = showPopupMenu && isHoveringPopover
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DynamicIslandBatteryView(
|
||||
batteryWidth: 30,
|
||||
isCharging: false,
|
||||
isInLowPowerMode: false,
|
||||
isPluggedIn: true,
|
||||
levelBattery: 80,
|
||||
maxCapacity: 100,
|
||||
timeToFullCharge: 10,
|
||||
isForNotification: false
|
||||
).frame(width: 200, height: 200)
|
||||
}
|
||||
147
DynamicIsland/components/Live activities/InlineHUD.swift
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
//
|
||||
// InlineHUDs.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 14/09/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct InlineHUD: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@Binding var type: SneakContentType
|
||||
@Binding var value: CGFloat
|
||||
@Binding var icon: String
|
||||
@Binding var hoverAnimation: Bool
|
||||
@Binding var gestureProgress: CGFloat
|
||||
var body: some View {
|
||||
HStack {
|
||||
HStack(spacing: 5) {
|
||||
Group {
|
||||
switch (type) {
|
||||
case .volume:
|
||||
if icon.isEmpty {
|
||||
Image(systemName: SpeakerSymbol(value))
|
||||
.contentTransition(.interpolate)
|
||||
.symbolVariant(value > 0 ? .none : .slash)
|
||||
.frame(width: 20, height: 15, alignment: .leading)
|
||||
} else {
|
||||
Image(systemName: icon)
|
||||
.contentTransition(.interpolate)
|
||||
.opacity(value.isZero ? 0.6 : 1)
|
||||
.scaleEffect(value.isZero ? 0.85 : 1)
|
||||
.frame(width: 20, height: 15, alignment: .leading)
|
||||
}
|
||||
case .brightness:
|
||||
Image(systemName: BrightnessSymbol(value))
|
||||
.contentTransition(.interpolate)
|
||||
.frame(width: 20, height: 15, alignment: .center)
|
||||
case .backlight:
|
||||
Image(systemName: "keyboard")
|
||||
.contentTransition(.interpolate)
|
||||
.frame(width: 20, height: 15, alignment: .center)
|
||||
case .mic:
|
||||
Image(systemName: "mic")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.symbolVariant(value > 0 ? .none : .slash)
|
||||
.contentTransition(.interpolate)
|
||||
.frame(width: 20, height: 15, alignment: .center)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.symbolVariant(.fill)
|
||||
|
||||
Text(Type2Name(type))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(1)
|
||||
.allowsTightening(true)
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
.frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading)
|
||||
|
||||
Rectangle()
|
||||
.fill(.black)
|
||||
.frame(width: vm.closedNotchSize.width - 20)
|
||||
|
||||
HStack {
|
||||
if (type == .mic) {
|
||||
Text(value.isZero ? "muted" : "unmuted")
|
||||
.foregroundStyle(.gray)
|
||||
.lineLimit(1)
|
||||
.allowsTightening(true)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.contentTransition(.interpolate)
|
||||
} else {
|
||||
HStack {
|
||||
DraggableProgressBar(value: $value)
|
||||
if (type == .volume && value.isZero) {
|
||||
Text("muted")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.gray)
|
||||
.lineLimit(1)
|
||||
.allowsTightening(true)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
.frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center)
|
||||
}
|
||||
.frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center)
|
||||
}
|
||||
|
||||
func SpeakerSymbol(_ value: CGFloat) -> String {
|
||||
switch(value) {
|
||||
case 0:
|
||||
return "speaker"
|
||||
case 0...0.3:
|
||||
return "speaker.wave.1"
|
||||
case 0.3...0.8:
|
||||
return "speaker.wave.2"
|
||||
case 0.8...1:
|
||||
return "speaker.wave.3"
|
||||
default:
|
||||
return "speaker.wave.2"
|
||||
}
|
||||
}
|
||||
|
||||
func BrightnessSymbol(_ value: CGFloat) -> String {
|
||||
switch(value) {
|
||||
case 0...0.6:
|
||||
return "sun.min"
|
||||
case 0.6...1:
|
||||
return "sun.max"
|
||||
default:
|
||||
return "sun.min"
|
||||
}
|
||||
}
|
||||
|
||||
func Type2Name(_ type: SneakContentType) -> String {
|
||||
switch(type) {
|
||||
case .volume:
|
||||
return "Volume"
|
||||
case .brightness:
|
||||
return "Brightness"
|
||||
case .backlight:
|
||||
return "Backlight"
|
||||
case .mic:
|
||||
return "Mic"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
InlineHUD(type: .constant(.brightness), value: .constant(0.4), icon: .constant(""), hoverAnimation: .constant(false), gestureProgress: .constant(0))
|
||||
.padding(.horizontal, 8)
|
||||
.background(Color.black)
|
||||
.padding()
|
||||
.environmentObject(DynamicIslandViewModel())
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
//
|
||||
// LiveActivityModifier.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 12/08/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum ActivityType {
|
||||
case mediaPlayback
|
||||
case charging
|
||||
case download
|
||||
}
|
||||
|
||||
struct LiveActivityModifier<Left: View, Right: View>: ViewModifier {
|
||||
let `for`: ActivityType
|
||||
let leftContent: () -> Left
|
||||
let rightContent: () -> Right
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(
|
||||
HStack {
|
||||
leftContent()
|
||||
Spacer()
|
||||
//.frame(minWidth: vm.closedNotchSize.width)
|
||||
rightContent()
|
||||
}
|
||||
.padding()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func liveActivity<Left: View, Right: View>(
|
||||
for activityId: ActivityType,
|
||||
@ViewBuilder left: @escaping () -> Left,
|
||||
@ViewBuilder right: @escaping () -> Right
|
||||
) -> some View {
|
||||
self.modifier(LiveActivityModifier(for: activityId, leftContent: left, rightContent: right))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
//
|
||||
// MarqueeTextView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 08/08/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SizePreferenceKey: PreferenceKey {
|
||||
static var defaultValue: CGSize = .zero
|
||||
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
struct MeasureSizeModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content.background(GeometryReader { geometry in
|
||||
Color.clear.preference(key: SizePreferenceKey.self, value: geometry.size)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct MarqueeText: View {
|
||||
@Binding var text: String
|
||||
let font: Font
|
||||
let nsFont: NSFont.TextStyle
|
||||
let textColor: Color
|
||||
let backgroundColor: Color
|
||||
let minDuration: Double
|
||||
let frameWidth: CGFloat
|
||||
|
||||
@State private var animate = false
|
||||
@State private var textSize: CGSize = .zero
|
||||
@State private var offset: CGFloat = 0
|
||||
|
||||
init(_ text: Binding<String>, font: Font = .body, nsFont: NSFont.TextStyle = .body, textColor: Color = .primary, backgroundColor: Color = .clear, minDuration: Double = 3.0, frameWidth: CGFloat = 200) {
|
||||
_text = text
|
||||
self.font = font
|
||||
self.nsFont = nsFont
|
||||
self.textColor = textColor
|
||||
self.backgroundColor = backgroundColor
|
||||
self.minDuration = minDuration
|
||||
self.frameWidth = frameWidth
|
||||
}
|
||||
|
||||
private var needsScrolling: Bool {
|
||||
textSize.width > frameWidth
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
HStack(spacing: 20) {
|
||||
Text(text)
|
||||
Text(text)
|
||||
.opacity(needsScrolling ? 1 : 0)
|
||||
}
|
||||
.id(text)
|
||||
.font(font)
|
||||
.foregroundColor(textColor)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.offset(x: self.animate ? offset : 0)
|
||||
.animation(
|
||||
self.animate ?
|
||||
.linear(duration: Double(textSize.width / 30))
|
||||
.delay(minDuration)
|
||||
.repeatForever(autoreverses: false) : .none,
|
||||
value: self.animate
|
||||
)
|
||||
.background(backgroundColor)
|
||||
.modifier(MeasureSizeModifier())
|
||||
.onPreferenceChange(SizePreferenceKey.self) { size in
|
||||
self.textSize = CGSize(width: size.width / 2, height: NSFont.preferredFont(forTextStyle: nsFont).pointSize)
|
||||
self.animate = false
|
||||
self.offset = 0
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01){
|
||||
if needsScrolling {
|
||||
self.animate = true
|
||||
self.offset = -(textSize.width + 10)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: frameWidth, alignment: .leading)
|
||||
.clipped()
|
||||
}
|
||||
.frame(height: textSize.height * 1.3)
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
//
|
||||
// SystemEventIndicatorModifier.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 12/08/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct SystemEventIndicatorModifier: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@Binding var eventType: SneakContentType
|
||||
@Binding var value: CGFloat {
|
||||
didSet {
|
||||
DispatchQueue.main.async {
|
||||
self.sendEventBack(value)
|
||||
self.vm.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
@Binding var icon: String
|
||||
let showSlider: Bool = false
|
||||
var sendEventBack: (CGFloat) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
switch (eventType) {
|
||||
case .volume:
|
||||
if icon.isEmpty {
|
||||
Image(systemName: SpeakerSymbol(value))
|
||||
.contentTransition(.interpolate)
|
||||
.symbolVariant(value > 0 ? .none : .slash)
|
||||
.frame(width: 20, height: 15, alignment: .leading)
|
||||
} else {
|
||||
Image(systemName: icon)
|
||||
.contentTransition(.interpolate)
|
||||
.opacity(value.isZero ? 0.6 : 1)
|
||||
.scaleEffect(value.isZero ? 0.85 : 1)
|
||||
.frame(width: 20, height: 15, alignment: .leading)
|
||||
}
|
||||
case .brightness:
|
||||
Image(systemName: "sun.max.fill")
|
||||
.contentTransition(.symbolEffect)
|
||||
.frame(width: 20, height: 15)
|
||||
.foregroundStyle(.white)
|
||||
case .backlight:
|
||||
Image(systemName: "keyboard")
|
||||
.contentTransition(.symbolEffect)
|
||||
.frame(width: 20, height: 15)
|
||||
.foregroundStyle(.white)
|
||||
case .mic:
|
||||
Image(systemName: "mic")
|
||||
.symbolVariant(value > 0 ? .none : .slash)
|
||||
.contentTransition(.interpolate)
|
||||
.frame(width: 20, height: 15)
|
||||
.foregroundStyle(.white)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
if (eventType != .mic) {
|
||||
DraggableProgressBar(value: $value)
|
||||
} else {
|
||||
Text("Mic \(value > 0 ? "unmuted" : "muted")")
|
||||
.foregroundStyle(.gray)
|
||||
.lineLimit(1)
|
||||
.allowsTightening(true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.symbolVariant(.fill)
|
||||
.imageScale(.large)
|
||||
}
|
||||
|
||||
func SpeakerSymbol(_ value: CGFloat) -> String {
|
||||
switch(value) {
|
||||
case 0:
|
||||
return "speaker.slash"
|
||||
case 0...0.3:
|
||||
return "speaker.wave.1"
|
||||
case 0.3...0.8:
|
||||
return "speaker.wave.2"
|
||||
case 0.8...1:
|
||||
return "speaker.wave.3"
|
||||
default:
|
||||
return "speaker.wave.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DraggableProgressBar: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@Binding var value: CGFloat
|
||||
|
||||
@State private var isDragging = false
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(.tertiary)
|
||||
Group {
|
||||
if Defaults[.enableGradient] {
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: Defaults[.systemEventIndicatorUseAccent] ? [Defaults[.accentColor], Defaults[.accentColor].ensureMinimumBrightness(factor: 0.2)] : [.white, .white.opacity(0.2)], startPoint: .trailing, endPoint: .leading))
|
||||
.frame(width: max(0, min(geo.size.width * value, geo.size.width)))
|
||||
.shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3)
|
||||
} else {
|
||||
Capsule()
|
||||
.fill(Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor] : .white)
|
||||
.frame(width: max(0, min(geo.size.width * value, geo.size.width)))
|
||||
.shadow(color: Defaults[.systemEventIndicatorShadow] ? Defaults[.systemEventIndicatorUseAccent] ? Defaults[.accentColor].ensureMinimumBrightness(factor: 0.7) : .white : .clear, radius: 8, x: 3)
|
||||
}
|
||||
}
|
||||
.opacity(value.isZero ? 0 : 1)
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { gesture in
|
||||
withAnimation(.smooth(duration: 0.3)) {
|
||||
isDragging = true
|
||||
updateValue(gesture: gesture, in: geo)
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.smooth(duration: 0.3)) {
|
||||
isDragging = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: Defaults[.inlineHUD] ? isDragging ? 8 : 5 : isDragging ? 9 : 6)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateValue(gesture: DragGesture.Value, in geometry: GeometryProxy) {
|
||||
let dragPosition = gesture.location.x
|
||||
let newValue = dragPosition / geometry.size.width
|
||||
|
||||
value = max(0, min(newValue, 1))
|
||||
}
|
||||
}
|
||||
33
DynamicIsland/components/Music/LottieAnimationView.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// LottieAnimationView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 2024. 10. 29..
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Lottie
|
||||
import LottieUI
|
||||
import Defaults
|
||||
|
||||
struct LottieAnimationView: View {
|
||||
let state1 = LUStateData(type: .loadedFrom(URL(string: "https://assets9.lottiefiles.com/packages/lf20_mniampqn.json")!), speed: 1.0, loopMode: .loop)
|
||||
@Default(.selectedVisualizer) var selectedVisualizer
|
||||
var body: some View {
|
||||
if selectedVisualizer == nil {
|
||||
LottieView(state: state1)
|
||||
} else {
|
||||
LottieView(
|
||||
state: LUStateData(
|
||||
type: .loadedFrom(selectedVisualizer!.url),
|
||||
speed: selectedVisualizer!.speed,
|
||||
loopMode: .loop
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
LottieAnimationView()
|
||||
}
|
||||
115
DynamicIsland/components/Music/MusicVisualizer.swift
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
//
|
||||
// MusicVisualizer.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 02/08/24.
|
||||
//
|
||||
import AppKit
|
||||
import Cocoa
|
||||
import SwiftUI
|
||||
|
||||
class AudioSpectrum: NSView {
|
||||
private var barLayers: [CAShapeLayer] = []
|
||||
private var isPlaying: Bool = true
|
||||
private var animationTimer: Timer?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
setupBars()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
wantsLayer = true
|
||||
setupBars()
|
||||
}
|
||||
|
||||
private func setupBars() {
|
||||
let barWidth: CGFloat = 2
|
||||
let barCount = 4
|
||||
let spacing: CGFloat = barWidth
|
||||
let totalWidth = CGFloat(barCount) * (barWidth + spacing)
|
||||
let totalHeight: CGFloat = 14
|
||||
frame.size = CGSize(width: totalWidth, height: totalHeight)
|
||||
|
||||
for i in 0 ..< barCount {
|
||||
let xPosition = CGFloat(i) * (barWidth + spacing)
|
||||
let barLayer = CAShapeLayer()
|
||||
barLayer.frame = CGRect(x: xPosition, y: 0, width: barWidth, height: totalHeight)
|
||||
barLayer.position = CGPoint(x: xPosition + barWidth / 2, y: totalHeight / 2)
|
||||
barLayer.fillColor = NSColor.white.cgColor
|
||||
|
||||
let path = NSBezierPath(roundedRect: CGRect(x: 0, y: 0, width: barWidth, height: totalHeight),
|
||||
xRadius: barWidth / 2,
|
||||
yRadius: barWidth / 2)
|
||||
barLayer.path = path.cgPath
|
||||
|
||||
barLayers.append(barLayer)
|
||||
layer?.addSublayer(barLayer)
|
||||
}
|
||||
}
|
||||
|
||||
private func startAnimating() {
|
||||
guard animationTimer == nil else { return }
|
||||
animationTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] _ in
|
||||
self?.updateBars()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopAnimating() {
|
||||
animationTimer?.invalidate()
|
||||
animationTimer = nil
|
||||
resetBars()
|
||||
}
|
||||
|
||||
private func updateBars() {
|
||||
for barLayer in barLayers {
|
||||
let animation = CABasicAnimation(keyPath: "transform.scale.y")
|
||||
animation.fromValue = barLayer.presentation()?.value(forKeyPath: "transform.scale.y") ?? 0.35
|
||||
animation.toValue = CGFloat.random(in: 0.35 ... 1.0)
|
||||
animation.duration = 0.3
|
||||
animation.autoreverses = true
|
||||
animation.fillMode = .forwards
|
||||
animation.isRemovedOnCompletion = false
|
||||
|
||||
barLayer.add(animation, forKey: "scaleY")
|
||||
}
|
||||
}
|
||||
|
||||
private func resetBars() {
|
||||
for barLayer in barLayers {
|
||||
barLayer.removeAllAnimations()
|
||||
barLayer.transform = CATransform3DMakeScale(1, 0.35, 1)
|
||||
}
|
||||
}
|
||||
|
||||
func setPlaying(_ playing: Bool) {
|
||||
isPlaying = playing
|
||||
if isPlaying {
|
||||
startAnimating()
|
||||
} else {
|
||||
stopAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AudioSpectrumView: NSViewRepresentable {
|
||||
@Binding var isPlaying: Bool
|
||||
|
||||
func makeNSView(context: Context) -> AudioSpectrum {
|
||||
let spectrum = AudioSpectrum()
|
||||
spectrum.setPlaying(isPlaying)
|
||||
return spectrum
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: AudioSpectrum, context: Context) {
|
||||
nsView.setPlaying(isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AudioSpectrumView(isPlaying: .constant(true))
|
||||
.frame(width: 16, height: 20)
|
||||
.padding()
|
||||
}
|
||||
107
DynamicIsland/components/Notch/BoringExtrasMenu.swift
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
//
|
||||
// BoringExtrasMenu.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 04/08/24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct BoringLargeButtons: View {
|
||||
var action: () -> Void
|
||||
var icon: Image
|
||||
var title: String
|
||||
var body: some View {
|
||||
Button (
|
||||
action:action,
|
||||
label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12.0).fill(.black).frame(width: 70, height: 70)
|
||||
VStack(spacing: 8) {
|
||||
icon.resizable()
|
||||
.aspectRatio(contentMode: .fit).frame(width:20)
|
||||
Text(title).font(.body)
|
||||
}
|
||||
}
|
||||
}).buttonStyle(PlainButtonStyle()).shadow(color: .black.opacity(0.5), radius: 10)
|
||||
}
|
||||
}
|
||||
|
||||
struct BoringExtrasMenu : View {
|
||||
@ObservedObject var vm: DynamicIslandViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack{
|
||||
HStack(spacing: 20) {
|
||||
hide
|
||||
settings
|
||||
close
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var github: some View {
|
||||
BoringLargeButtons(
|
||||
action: {
|
||||
NSWorkspace.shared.open(productPage)
|
||||
},
|
||||
icon: Image(.github),
|
||||
title: "Checkout"
|
||||
)
|
||||
}
|
||||
|
||||
var donate: some View {
|
||||
BoringLargeButtons(
|
||||
action: {
|
||||
NSWorkspace.shared.open(sponsorPage)
|
||||
},
|
||||
icon: Image(systemName: "heart.fill"),
|
||||
title: "Love Us"
|
||||
)
|
||||
}
|
||||
|
||||
var settings: some View {
|
||||
SettingsLink(label: {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12.0).fill(.black).frame(width: 70, height: 70)
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "gear").resizable()
|
||||
.aspectRatio(contentMode: .fit).frame(width:20)
|
||||
Text("Settings").font(.body)
|
||||
}
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle()).shadow(color: .black.opacity(0.5), radius: 10)
|
||||
}
|
||||
|
||||
var hide: some View {
|
||||
BoringLargeButtons(
|
||||
action: {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
//vm.openMusic()
|
||||
}
|
||||
},
|
||||
icon: Image(systemName: "arrow.down.forward.and.arrow.up.backward"),
|
||||
title: "Hide"
|
||||
)
|
||||
}
|
||||
|
||||
var close: some View {
|
||||
BoringLargeButtons(
|
||||
action: {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: Image(systemName: "xmark"),
|
||||
title: "Exit"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
BoringExtrasMenu(vm: .init())
|
||||
}
|
||||
85
DynamicIsland/components/Notch/DynamicIslandHeader.swift
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
//
|
||||
// DynamicIslandHeader.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 04/08/24.
|
||||
//
|
||||
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct DynamicIslandHeader: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@StateObject var tvm = TrayDrop.shared
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
HStack {
|
||||
if (!tvm.isEmpty || coordinator.alwaysShowTabs) && Defaults[.dynamicShelf] {
|
||||
TabSelectionView()
|
||||
} else if vm.notchState == .open {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.opacity(vm.notchState == .closed ? 0 : 1)
|
||||
.blur(radius: vm.notchState == .closed ? 20 : 0)
|
||||
.animation(.smooth.delay(0.1), value: vm.notchState)
|
||||
.zIndex(2)
|
||||
|
||||
if vm.notchState == .open {
|
||||
Rectangle()
|
||||
.fill(NSScreen.screens
|
||||
.first(where: { $0.localizedName == coordinator.selectedScreen })?.safeAreaInsets.top ?? 0 > 0 ? .black : .clear)
|
||||
.frame(width: vm.closedNotchSize.width)
|
||||
.mask {
|
||||
NotchShape()
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 4) {
|
||||
if vm.notchState == .open {
|
||||
if Defaults[.settingsIconInNotch] {
|
||||
SettingsLink(label: {
|
||||
Capsule()
|
||||
.fill(.black)
|
||||
.frame(width: 30, height: 30)
|
||||
.overlay {
|
||||
Image(systemName: "gear")
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.imageScale(.medium)
|
||||
}
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
if Defaults[.showBatteryIndicator] {
|
||||
DynamicIslandBatteryView(
|
||||
batteryWidth: 30,
|
||||
isCharging: batteryModel.isCharging,
|
||||
isInLowPowerMode: batteryModel.isInLowPowerMode,
|
||||
isPluggedIn: batteryModel.isPluggedIn,
|
||||
levelBattery: batteryModel.levelBattery,
|
||||
maxCapacity: batteryModel.maxCapacity,
|
||||
timeToFullCharge: batteryModel.timeToFullCharge,
|
||||
isForNotification: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.opacity(vm.notchState == .closed ? 0 : 1)
|
||||
.blur(radius: vm.notchState == .closed ? 20 : 0)
|
||||
.animation(.smooth.delay(0.1), value: vm.notchState)
|
||||
.zIndex(2)
|
||||
}
|
||||
.foregroundColor(.gray)
|
||||
.environmentObject(vm)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DynamicIslandHeader().environmentObject(DynamicIslandViewModel())
|
||||
}
|
||||
50
DynamicIsland/components/Notch/DynamicIslandWindow.swift
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//
|
||||
// DynamicIslandWindow.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 06/08/24.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
class DynamicIslandWindow: NSPanel {
|
||||
override init(
|
||||
contentRect: NSRect,
|
||||
styleMask: NSWindow.StyleMask,
|
||||
backing: NSWindow.BackingStoreType,
|
||||
defer flag: Bool
|
||||
) {
|
||||
super.init(
|
||||
contentRect: contentRect,
|
||||
styleMask: styleMask,
|
||||
backing: backing,
|
||||
defer: flag
|
||||
)
|
||||
|
||||
isFloatingPanel = true
|
||||
isOpaque = false
|
||||
titleVisibility = .hidden
|
||||
titlebarAppearsTransparent = true
|
||||
backgroundColor = .clear
|
||||
isMovable = false
|
||||
|
||||
collectionBehavior = [
|
||||
.fullScreenAuxiliary,
|
||||
.stationary,
|
||||
.canJoinAllSpaces,
|
||||
.ignoresCycle,
|
||||
]
|
||||
|
||||
isReleasedWhenClosed = false
|
||||
level = .mainMenu + 3
|
||||
hasShadow = false
|
||||
}
|
||||
|
||||
override var canBecomeKey: Bool {
|
||||
false
|
||||
}
|
||||
|
||||
override var canBecomeMain: Bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
342
DynamicIsland/components/Notch/NotchHomeView.swift
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
//
|
||||
// NotchHomeView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Hugo Persson on 2024-08-18.
|
||||
// Modified by Harsh Vardhan Goswami & Richard Kunkli
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Music Player Components
|
||||
|
||||
struct MusicPlayerView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
let albumArtNamespace: Namespace.ID
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace)
|
||||
MusicControlsView().drawingGroup().compositingGroup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AlbumArtView: View {
|
||||
@ObservedObject var musicManager = MusicManager.shared
|
||||
@ObservedObject var vm: DynamicIslandViewModel
|
||||
let albumArtNamespace: Namespace.ID
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
if Defaults[.lightingEffect] {
|
||||
albumArtBackground
|
||||
}
|
||||
albumArtButton
|
||||
}
|
||||
}
|
||||
|
||||
private var albumArtBackground: some View {
|
||||
Color.clear
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.background(
|
||||
Image(nsImage: musicManager.albumArt)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.opened : MusicPlayerImageSizes.cornerRadiusInset.closed))
|
||||
.scaleEffect(x: 1.3, y: 1.4)
|
||||
.rotationEffect(.degrees(92))
|
||||
.blur(radius: 35)
|
||||
.opacity(min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3)))
|
||||
}
|
||||
|
||||
private var albumArtButton: some View {
|
||||
Button {
|
||||
musicManager.openMusicApp()
|
||||
} label: {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
albumArtImage
|
||||
appIconOverlay
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.opacity(musicManager.isPlaying ? 1 : 0.4)
|
||||
.scaleEffect(musicManager.isPlaying ? 1 : 0.85)
|
||||
}
|
||||
|
||||
private var albumArtImage: some View {
|
||||
Color.clear
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.background(
|
||||
Image(nsImage: musicManager.albumArt)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: musicManager.isFlipping)
|
||||
)
|
||||
.clipped()
|
||||
.clipShape(RoundedRectangle(cornerRadius: Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.opened : MusicPlayerImageSizes.cornerRadiusInset.closed))
|
||||
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var appIconOverlay: some View {
|
||||
if vm.notchState == .open && !musicManager.usingAppIconForArtwork {
|
||||
AppIcon(for: musicManager.bundleIdentifier ?? "com.apple.Music")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 30, height: 30)
|
||||
.offset(x: 10, y: 10)
|
||||
.transition(.scale.combined(with: .opacity).animation(.bouncy.delay(0.3)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MusicControlsView: View {
|
||||
@ObservedObject var musicManager = MusicManager.shared
|
||||
@State private var sliderValue: Double = 0
|
||||
@State private var dragging: Bool = false
|
||||
@State private var lastDragged: Date = .distantPast
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
songInfoAndSlider
|
||||
playbackControls
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.frame(minWidth: Defaults[.showMirror] && Defaults[.showCalendar] ? 140 : 180)
|
||||
}
|
||||
|
||||
private var songInfoAndSlider: some View {
|
||||
GeometryReader { geo in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
songInfo(width: geo.size.width)
|
||||
musicSlider
|
||||
}
|
||||
}
|
||||
.padding(.top, 10)
|
||||
.padding(.leading, 5)
|
||||
}
|
||||
|
||||
private func songInfo(width: CGFloat) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
MarqueeText($musicManager.songTitle, font: .headline, nsFont: .headline, textColor: .white, frameWidth: width)
|
||||
MarqueeText(
|
||||
$musicManager.artistName,
|
||||
font: .headline,
|
||||
nsFont: .headline,
|
||||
textColor: Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor)
|
||||
.ensureMinimumBrightness(factor: 0.6) : .gray,
|
||||
frameWidth: width
|
||||
)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
private var musicSlider: some View {
|
||||
TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in
|
||||
MusicSliderView(
|
||||
sliderValue: $sliderValue,
|
||||
duration: $musicManager.songDuration,
|
||||
lastDragged: $lastDragged,
|
||||
color: musicManager.avgColor,
|
||||
dragging: $dragging,
|
||||
currentDate: timeline.date,
|
||||
lastUpdated: musicManager.lastUpdated,
|
||||
ignoreLastUpdated: musicManager.ignoreLastUpdated,
|
||||
timestampDate: musicManager.timestampDate,
|
||||
elapsedTime: musicManager.elapsedTime,
|
||||
playbackRate: musicManager.playbackRate,
|
||||
isPlaying: musicManager.isPlaying
|
||||
) { newValue in
|
||||
MusicManager.shared.seek(to: newValue)
|
||||
}
|
||||
.padding(.top, 5)
|
||||
.frame(height: 36)
|
||||
}
|
||||
}
|
||||
|
||||
private var playbackControls: some View {
|
||||
HStack(spacing: 8) {
|
||||
HoverButton(icon: "backward.fill", scale: .medium) {
|
||||
MusicManager.shared.previousTrack()
|
||||
}
|
||||
HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) {
|
||||
MusicManager.shared.togglePlay()
|
||||
}
|
||||
HoverButton(icon: "forward.fill", scale: .medium) {
|
||||
MusicManager.shared.nextTrack()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main View
|
||||
|
||||
struct NotchHomeView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@EnvironmentObject var webcamManager: WebcamManager
|
||||
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
let albumArtNamespace: Namespace.ID
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !coordinator.firstLaunch {
|
||||
mainContent
|
||||
}
|
||||
}
|
||||
.transition(.opacity.combined(with: .blurReplace))
|
||||
}
|
||||
|
||||
private var mainContent: some View {
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
MusicPlayerView(albumArtNamespace: albumArtNamespace)
|
||||
|
||||
if Defaults[.showCalendar] {
|
||||
CalendarView()
|
||||
.onHover { isHovering in
|
||||
vm.isHoveringCalendar = isHovering
|
||||
}
|
||||
.environmentObject(vm)
|
||||
}
|
||||
|
||||
if Defaults[.showMirror] && webcamManager.cameraAvailable {
|
||||
CameraPreviewView(webcamManager: webcamManager)
|
||||
.scaledToFit()
|
||||
.opacity(vm.notchState == .closed ? 0 : 1)
|
||||
.blur(radius: vm.notchState == .closed ? 20 : 0)
|
||||
}
|
||||
}
|
||||
.transition(.opacity.animation(.smooth.speed(0.9))
|
||||
.combined(with: .blurReplace.animation(.smooth.speed(0.9)))
|
||||
.combined(with: .move(edge: .top)))
|
||||
.blur(radius: vm.notchState == .closed ? 30 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
struct MusicSliderView: View {
|
||||
@Binding var sliderValue: Double
|
||||
@Binding var duration: Double
|
||||
@Binding var lastDragged: Date
|
||||
var color: NSColor
|
||||
@Binding var dragging: Bool
|
||||
let currentDate: Date
|
||||
let lastUpdated: Date
|
||||
let ignoreLastUpdated: Bool
|
||||
let timestampDate: Date
|
||||
let elapsedTime: Double
|
||||
let playbackRate: Double
|
||||
let isPlaying: Bool
|
||||
var onValueChange: (Double) -> Void
|
||||
|
||||
var currentElapsedTime: Double {
|
||||
// A small buffer is needed to ensure a meaningful difference between the two dates
|
||||
guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -0.0001, (timestampDate > lastUpdated || ignoreLastUpdated) else { return sliderValue }
|
||||
let timeDifference = isPlaying ? currentDate.timeIntervalSince(timestampDate) : 0
|
||||
let elapsed = elapsedTime + (timeDifference * playbackRate)
|
||||
return min(elapsed, duration)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
CustomSlider(
|
||||
value: $sliderValue,
|
||||
range: 0 ... duration,
|
||||
color: Defaults[.sliderColor] == SliderColorEnum.albumArt ? Color(
|
||||
nsColor: color
|
||||
).ensureMinimumBrightness(factor: 0.8) : Defaults[.sliderColor] == SliderColorEnum.accent ? Defaults[.accentColor] : .white,
|
||||
dragging: $dragging,
|
||||
lastDragged: $lastDragged,
|
||||
onValueChange: onValueChange
|
||||
)
|
||||
.frame(height: 10, alignment: .center)
|
||||
HStack {
|
||||
Text(timeString(from: sliderValue))
|
||||
Spacer()
|
||||
Text(timeString(from: duration))
|
||||
}
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: color)
|
||||
.ensureMinimumBrightness(factor: 0.6) : .gray)
|
||||
.font(.caption)
|
||||
}
|
||||
.onChange(of: currentDate) {
|
||||
sliderValue = currentElapsedTime
|
||||
}
|
||||
}
|
||||
|
||||
func timeString(from seconds: Double) -> String {
|
||||
let totalMinutes = Int(seconds) / 60
|
||||
let remainingSeconds = Int(seconds) % 60
|
||||
let hours = totalMinutes / 60
|
||||
let minutes = totalMinutes % 60
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d", hours, minutes, remainingSeconds)
|
||||
} else {
|
||||
return String(format: "%d:%02d", minutes, remainingSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomSlider: View {
|
||||
@Binding var value: Double
|
||||
var range: ClosedRange<Double>
|
||||
var color: Color = .white
|
||||
@Binding var dragging: Bool
|
||||
@Binding var lastDragged: Date
|
||||
var onValueChange: ((Double) -> Void)?
|
||||
var thumbSize: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let width = geometry.size.width
|
||||
let height = CGFloat(dragging ? 9 : 5)
|
||||
let rangeSpan = range.upperBound - range.lowerBound
|
||||
|
||||
let filledTrackWidth = min(rangeSpan == .zero ? 0 : ((value - range.lowerBound) / rangeSpan) * width, width)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
// Background track
|
||||
Rectangle()
|
||||
.fill(.gray.opacity(0.3))
|
||||
.frame(height: height)
|
||||
|
||||
// Filled track
|
||||
Rectangle()
|
||||
.fill(color)
|
||||
.frame(width: filledTrackWidth, height: height)
|
||||
}
|
||||
.cornerRadius(height / 2)
|
||||
.frame(height: 10)
|
||||
.contentShape(Rectangle())
|
||||
.highPriorityGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { gesture in
|
||||
withAnimation {
|
||||
dragging = true
|
||||
}
|
||||
let newValue = range.lowerBound + Double(gesture.location.x / width) * rangeSpan
|
||||
value = min(max(newValue, range.lowerBound), range.upperBound)
|
||||
}
|
||||
.onEnded { _ in
|
||||
onValueChange?(value)
|
||||
dragging = false
|
||||
lastDragged = Date()
|
||||
}
|
||||
)
|
||||
.animation(.bouncy.speed(1.4), value: dragging)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NotchHomeView(albumArtNamespace: Namespace().wrappedValue)
|
||||
.environmentObject(DynamicIslandViewModel())
|
||||
.environmentObject(WebcamManager())
|
||||
}
|
||||
126
DynamicIsland/components/Notch/NotchShape.swift
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
//
|
||||
// NotchShape.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Kai Azim on 2023-08-24.
|
||||
// Original source: https://github.com/MrKai77/DynamicNotchKit
|
||||
// Modified by Alexander Greco on 2025-05-18.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NotchShape: Shape {
|
||||
private var topCornerRadius: CGFloat
|
||||
private var bottomCornerRadius: CGFloat
|
||||
|
||||
init(
|
||||
topCornerRadius: CGFloat? = nil,
|
||||
bottomCornerRadius: CGFloat? = nil
|
||||
) {
|
||||
self.topCornerRadius = topCornerRadius ?? 6
|
||||
self.bottomCornerRadius = bottomCornerRadius ?? 14
|
||||
}
|
||||
|
||||
var animatableData: AnimatablePair<CGFloat, CGFloat> {
|
||||
get {
|
||||
.init(
|
||||
topCornerRadius,
|
||||
bottomCornerRadius
|
||||
)
|
||||
}
|
||||
set {
|
||||
topCornerRadius = newValue.first
|
||||
bottomCornerRadius = newValue.second
|
||||
}
|
||||
}
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
|
||||
path.move(
|
||||
to: CGPoint(
|
||||
x: rect.minX,
|
||||
y: rect.minY
|
||||
)
|
||||
)
|
||||
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(
|
||||
x: rect.minX + topCornerRadius,
|
||||
y: rect.minY + topCornerRadius
|
||||
),
|
||||
control: CGPoint(
|
||||
x: rect.minX + topCornerRadius,
|
||||
y: rect.minY
|
||||
)
|
||||
)
|
||||
|
||||
path.addLine(
|
||||
to: CGPoint(
|
||||
x: rect.minX + topCornerRadius,
|
||||
y: rect.maxY - bottomCornerRadius
|
||||
)
|
||||
)
|
||||
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(
|
||||
x: rect.minX + topCornerRadius + bottomCornerRadius,
|
||||
y: rect.maxY
|
||||
),
|
||||
control: CGPoint(
|
||||
x: rect.minX + topCornerRadius,
|
||||
y: rect.maxY
|
||||
)
|
||||
)
|
||||
|
||||
path.addLine(
|
||||
to: CGPoint(
|
||||
x: rect.maxX - topCornerRadius - bottomCornerRadius,
|
||||
y: rect.maxY
|
||||
)
|
||||
)
|
||||
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(
|
||||
x: rect.maxX - topCornerRadius,
|
||||
y: rect.maxY - bottomCornerRadius
|
||||
),
|
||||
control: CGPoint(
|
||||
x: rect.maxX - topCornerRadius,
|
||||
y: rect.maxY
|
||||
)
|
||||
)
|
||||
|
||||
path.addLine(
|
||||
to: CGPoint(
|
||||
x: rect.maxX - topCornerRadius,
|
||||
y: rect.minY + topCornerRadius
|
||||
)
|
||||
)
|
||||
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(
|
||||
x: rect.maxX,
|
||||
y: rect.minY
|
||||
),
|
||||
control: CGPoint(
|
||||
x: rect.maxX - topCornerRadius,
|
||||
y: rect.minY
|
||||
)
|
||||
)
|
||||
|
||||
path.addLine(
|
||||
to: CGPoint(
|
||||
x: rect.minX,
|
||||
y: rect.minY
|
||||
)
|
||||
)
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NotchShape(topCornerRadius: 6, bottomCornerRadius: 14)
|
||||
.frame(width: 200, height: 32)
|
||||
.padding(10)
|
||||
}
|
||||
64
DynamicIsland/components/Notch/NotchShelfView.swift
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
struct NotchShelfView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@StateObject var tvm = TrayDrop.shared
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
AirDropView()
|
||||
panel
|
||||
.onDrop(of: [.data], isTargeted: $vm.dropZoneTargeting) { providers in
|
||||
vm.dropEvent = true
|
||||
DispatchQueue.global().async {
|
||||
tvm.load(providers)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var panel: some View {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.strokeBorder(style: StrokeStyle(lineWidth: 4, dash: [10]))
|
||||
.foregroundStyle(.white.opacity(0.1))
|
||||
.overlay {
|
||||
content
|
||||
.padding()
|
||||
}
|
||||
.animation(vm.animation, value: tvm.items)
|
||||
.animation(vm.animation, value: tvm.isLoading)
|
||||
}
|
||||
|
||||
var content: some View {
|
||||
Group {
|
||||
if tvm.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "tray.and.arrow.down")
|
||||
.symbolVariant(.fill)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(.white, .gray)
|
||||
.imageScale(.large)
|
||||
|
||||
Text("Drop files here")
|
||||
.foregroundStyle(.gray)
|
||||
.font(.system(.title3, design: .rounded))
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
} else {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: spacing) {
|
||||
ForEach(tvm.items) { item in
|
||||
DropItemView(item: item)
|
||||
}
|
||||
}
|
||||
.padding(spacing)
|
||||
}
|
||||
.padding(-spacing)
|
||||
.scrollIndicators(.never)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
80
DynamicIsland/components/Onboarding/ActivationView.swift
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//
|
||||
// OnboardingSettings.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 2024. 09. 26..
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ActivationWindow: View {
|
||||
@State private var email: String = ""
|
||||
@State private var key: String = ""
|
||||
var body: some View {
|
||||
VStack {
|
||||
Image("logo")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(height: 80)
|
||||
.padding(.top, 30)
|
||||
.padding(.bottom, 10)
|
||||
Text("Activate your license")
|
||||
.font(.largeTitle.bold())
|
||||
.fontDesign(.rounded)
|
||||
Text("Transform your notch truly yours")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.title2)
|
||||
.padding(.bottom, 20)
|
||||
Group {
|
||||
TextField("Email address", text: $email)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
|
||||
TextField("License key", text: $key)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.white.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.textFieldStyle(PlainTextFieldStyle())
|
||||
.scrollContentBackground(.hidden)
|
||||
.toggleStyle(.switch)
|
||||
Spacer()
|
||||
VStack(alignment: .center, spacing: 14) {
|
||||
HStack(alignment: .center) {
|
||||
HStack {
|
||||
Button {} label: {
|
||||
Text("Cancel")
|
||||
.padding(.horizontal, 18)
|
||||
}
|
||||
.buttonStyle(AccessoryBarButtonStyle())
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Image("dynamicisland")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(height: 18)
|
||||
.offset(y: 4)
|
||||
.blendMode(.overlay)
|
||||
HStack {
|
||||
Button {} label: {
|
||||
Text("Activate")
|
||||
.padding(.horizontal, 18)
|
||||
}
|
||||
.buttonStyle(BorderedProminentButtonStyle())
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
|
||||
}
|
||||
.controlSize(.extraLarge)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.ignoresSafeArea()
|
||||
.background {
|
||||
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.frame(width: 350, height: 350)
|
||||
}
|
||||
}
|
||||
105
DynamicIsland/components/Onboarding/ProOnboarding.swift
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
//
|
||||
// ProOnboarding.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 2024. 09. 26..
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftUIIntrospect
|
||||
|
||||
struct ProOnboard: View {
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
ZStack {
|
||||
Image("spotlight")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.padding(.bottom)
|
||||
.blur(radius: 3)
|
||||
.offset(y: -5)
|
||||
.background(SparkleView().opacity(0.6))
|
||||
VStack(spacing: 8) {
|
||||
Image("logo")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 100, height: 100)
|
||||
.padding(.bottom, 8)
|
||||
Text("TheDynamicIsland")
|
||||
.font(.system(.largeTitle, design: .serif))
|
||||
Text("Welcome")
|
||||
.font(.title)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 30)
|
||||
if false {
|
||||
Text("PRO")
|
||||
.font(.system(size: 18, design: .rounded))
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 3)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(LinearGradient(colors: [.white.opacity(0.7), .white.opacity(0.3)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
.strokeBorder(LinearGradient(stops: [.init(color: .white.opacity(0.7), location: 0.3), .init(color: .clear, location: 0.6)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
.blendMode(.overlay)
|
||||
)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
Button {
|
||||
if let url = URL(string: "https://buymeacoffee.com/jfxh67wvfxq") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("Buy us a coffee", systemImage: "cup.and.saucer.fill")
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.black)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.yellow)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Button {
|
||||
NSApp.keyWindow?.close()
|
||||
} label: {
|
||||
Text("Get started")
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(BorderedProminentButtonStyle())
|
||||
}
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
Image("dynamicisland")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(height: 22)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
|
||||
.padding()
|
||||
.padding(.bottom, 36)
|
||||
.blendMode(.overlay)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.ignoresSafeArea()
|
||||
.background {
|
||||
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.onAppear {
|
||||
NSApp.mainWindow?.standardWindowButton(.zoomButton)?.isHidden = true
|
||||
NSApp.mainWindow?.standardWindowButton(.closeButton)?.isHidden = true
|
||||
NSApp.mainWindow?.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||
NSApp.mainWindow?.styleMask.remove(.resizable)
|
||||
}
|
||||
.frame(width: 350, height: 500)
|
||||
.introspect(.window, on: .macOS(.v14, .v15)) { window in
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.titleVisibility = .hidden
|
||||
window.center()
|
||||
}
|
||||
}
|
||||
}
|
||||
76
DynamicIsland/components/Onboarding/SparkleView.swift
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
//
|
||||
// SparkleView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 2024. 09. 26..
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
class SparkleNSView: NSView {
|
||||
private var emitterLayer: CAEmitterLayer?
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
self.wantsLayer = true
|
||||
setupEmitterLayer()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setupEmitterLayer() {
|
||||
let emitterLayer = CAEmitterLayer()
|
||||
emitterLayer.emitterShape = .rectangle
|
||||
emitterLayer.emitterMode = .surface
|
||||
emitterLayer.renderMode = .oldestFirst
|
||||
|
||||
let cell = CAEmitterCell()
|
||||
cell.contents = NSImage(named: "sparkle")?.cgImage(forProposedRect: nil, context: nil, hints: nil)
|
||||
cell.birthRate = 50
|
||||
cell.lifetime = 5
|
||||
cell.velocity = 10
|
||||
cell.velocityRange = 5
|
||||
cell.emissionRange = .pi * 2
|
||||
cell.scale = 0.2
|
||||
cell.scaleRange = 0.1
|
||||
cell.alphaSpeed = -0.5
|
||||
cell.yAcceleration = 10 // Add a slight downward motion
|
||||
|
||||
emitterLayer.emitterCells = [cell]
|
||||
|
||||
self.layer?.addSublayer(emitterLayer)
|
||||
self.emitterLayer = emitterLayer
|
||||
|
||||
updateEmitterForCurrentBounds()
|
||||
}
|
||||
|
||||
private func updateEmitterForCurrentBounds() {
|
||||
guard let emitterLayer = self.emitterLayer else { return }
|
||||
|
||||
emitterLayer.frame = self.bounds
|
||||
emitterLayer.emitterSize = self.bounds.size
|
||||
emitterLayer.emitterPosition = CGPoint(x: bounds.width / 2, y: bounds.height / 2)
|
||||
|
||||
// Adjust birth rate based on view size
|
||||
let area = bounds.width * bounds.height
|
||||
let baseBirthRate: Float = 50
|
||||
let adjustedBirthRate = 20 // Assuming 200x200 as base size
|
||||
emitterLayer.emitterCells?.first?.birthRate = Float(adjustedBirthRate)
|
||||
}
|
||||
|
||||
override func setFrameSize(_ newSize: NSSize) {
|
||||
super.setFrameSize(newSize)
|
||||
updateEmitterForCurrentBounds()
|
||||
}
|
||||
}
|
||||
|
||||
struct SparkleView: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> SparkleNSView {
|
||||
return SparkleNSView()
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: SparkleNSView, context: Context) {}
|
||||
}
|
||||
63
DynamicIsland/components/ProgressIndicator.swift
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
//
|
||||
// ProgressIndicator.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 11/08/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct CircularProgressView: View {
|
||||
let progress: Double
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(
|
||||
Color.white.opacity(0.2),
|
||||
lineWidth: 6
|
||||
)
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(
|
||||
color,
|
||||
// 1
|
||||
style: StrokeStyle(
|
||||
lineWidth: 6,
|
||||
lineCap: .round
|
||||
)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProgressIndicatorType {
|
||||
case circle
|
||||
case text
|
||||
}
|
||||
|
||||
|
||||
// based on type .circle or .text
|
||||
struct ProgressIndicator: View {
|
||||
var type: ProgressIndicatorType
|
||||
var progress: Double
|
||||
var color: Color
|
||||
|
||||
var body: some View {
|
||||
switch type {
|
||||
case .circle:
|
||||
CircularProgressView(progress: progress, color: color).frame(
|
||||
width: 20, height: 20)
|
||||
case .text:
|
||||
Text("\(Int(progress * 100))%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ProgressIndicator(type: .circle, progress: 0.8, color: Color.blue).padding()
|
||||
.frame(width: 200, height: 200)
|
||||
}
|
||||
54
DynamicIsland/components/Settings/EditPanelView.swift
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//
|
||||
// EditPanelView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 12/08/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct EditPanelView: View {
|
||||
@State var wallpaperPath: URL?
|
||||
var body: some View {
|
||||
VStack {
|
||||
HStack {
|
||||
Text("Edit layout")
|
||||
.font(.system(.largeTitle, design: .rounded))
|
||||
.foregroundColor(.white.opacity(0.5))
|
||||
Spacer()
|
||||
Button {
|
||||
exit(0)
|
||||
} label: {
|
||||
Label("Close", systemImage: "xmark")
|
||||
}
|
||||
.controlSize(.extraLarge)
|
||||
.buttonStyle(AccessoryBarButtonStyle())
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
EditPanelView()
|
||||
}
|
||||
|
||||
struct VisualEffectView: NSViewRepresentable {
|
||||
let material: NSVisualEffectView.Material
|
||||
let blendingMode: NSVisualEffectView.BlendingMode
|
||||
|
||||
func makeNSView(context _: Context) -> NSVisualEffectView {
|
||||
let visualEffectView = NSVisualEffectView()
|
||||
visualEffectView.material = material
|
||||
visualEffectView.blendingMode = blendingMode
|
||||
visualEffectView.state = NSVisualEffectView.State.active
|
||||
visualEffectView.isEmphasized = true
|
||||
return visualEffectView
|
||||
}
|
||||
|
||||
func updateNSView(_ visualEffectView: NSVisualEffectView, context _: Context) {
|
||||
visualEffectView.material = material
|
||||
visualEffectView.blendingMode = blendingMode
|
||||
}
|
||||
}
|
||||
28
DynamicIsland/components/Settings/ListItemPopover.swift
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
// ListItemPopover.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 15/09/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ListItemPopover<Content: View>: View {
|
||||
let content: () -> Content
|
||||
|
||||
@State private var isPresented: Bool = false
|
||||
var body: some View {
|
||||
Button {
|
||||
isPresented.toggle()
|
||||
} label: {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.controlSize(.regular)
|
||||
.popover(isPresented: $isPresented, attachmentAnchor: .rect(.bounds), arrowEdge: .trailing, content: {
|
||||
content()
|
||||
.padding()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
1172
DynamicIsland/components/Settings/SettingsView.swift
Normal file
67
DynamicIsland/components/Settings/SoftwareUpdater.swift
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
//
|
||||
// SoftwareUpdater.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 09/08/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
final class CheckForUpdatesViewModel: ObservableObject {
|
||||
@Published var canCheckForUpdates = false
|
||||
|
||||
init(updater: SPUUpdater) {
|
||||
updater.publisher(for: \.canCheckForUpdates)
|
||||
.assign(to: &$canCheckForUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
struct CheckForUpdatesView: View {
|
||||
@ObservedObject private var checkForUpdatesViewModel: CheckForUpdatesViewModel
|
||||
private let updater: SPUUpdater
|
||||
|
||||
init(updater: SPUUpdater) {
|
||||
self.updater = updater
|
||||
|
||||
// Create our view model for our CheckForUpdatesView
|
||||
self.checkForUpdatesViewModel = CheckForUpdatesViewModel(updater: updater)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button("Check for Updates…", action: updater.checkForUpdates)
|
||||
.disabled(!checkForUpdatesViewModel.canCheckForUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
struct UpdaterSettingsView: View {
|
||||
private let updater: SPUUpdater
|
||||
|
||||
@State private var automaticallyChecksForUpdates: Bool
|
||||
@State private var automaticallyDownloadsUpdates: Bool
|
||||
|
||||
init(updater: SPUUpdater) {
|
||||
self.updater = updater
|
||||
self.automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates
|
||||
self.automaticallyDownloadsUpdates = updater.automaticallyDownloadsUpdates
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Toggle("Automatically check for updates", isOn: $automaticallyChecksForUpdates)
|
||||
.onChange(of: automaticallyChecksForUpdates) { _, newValue in
|
||||
updater.automaticallyChecksForUpdates = newValue
|
||||
}
|
||||
|
||||
Toggle("Automatically download updates", isOn: $automaticallyDownloadsUpdates)
|
||||
.disabled(!automaticallyChecksForUpdates)
|
||||
.onChange(of: automaticallyDownloadsUpdates) { _, newValue in
|
||||
updater.automaticallyDownloadsUpdates = newValue
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Software updates")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
40
DynamicIsland/components/Shelf/AirDrop.swift
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
//
|
||||
// AirDrop.swift
|
||||
// NotchDrop
|
||||
//
|
||||
// Created by 秋星桥 on 2024/7/7.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
class AirDrop: NSObject, NSSharingServiceDelegate {
|
||||
let files: [URL]
|
||||
|
||||
init(files: [URL]) {
|
||||
self.files = files
|
||||
super.init()
|
||||
}
|
||||
|
||||
func begin() {
|
||||
do {
|
||||
try sendEx(files)
|
||||
} catch {
|
||||
NSAlert.popError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendEx(_ files: [URL]) throws {
|
||||
guard let service = NSSharingService(named: .sendViaAirDrop) else {
|
||||
throw NSError(domain: "AirDrop", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: NSLocalizedString("AirDrop service not available", comment: ""),
|
||||
])
|
||||
}
|
||||
guard service.canPerform(withItems: files) else {
|
||||
throw NSError(domain: "AirDrop", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: NSLocalizedString("AirDrop service not available", comment: ""),
|
||||
])
|
||||
}
|
||||
service.delegate = self
|
||||
service.perform(withItems: files)
|
||||
}
|
||||
}
|
||||
70
DynamicIsland/components/Shelf/AirDropView.swift
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
//
|
||||
// AirDrop+View.swift
|
||||
// NotchDrop
|
||||
//
|
||||
// Created by 秋星桥 on 2024/7/8.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct AirDropView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
|
||||
@State var trigger: UUID = .init()
|
||||
@State var targeting = false
|
||||
|
||||
var body: some View {
|
||||
dropArea
|
||||
.onDrop(of: [.data], isTargeted: $vm.dropZoneTargeting) { providers in
|
||||
trigger = .init()
|
||||
vm.dropEvent = true
|
||||
DispatchQueue.global().async { beginDrop(providers) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var dropArea: some View {
|
||||
Rectangle()
|
||||
.fill(.white.opacity(0.1))
|
||||
.opacity(0.5)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.overlay { dropLabel }
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
var dropLabel: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "airplayaudio")
|
||||
Text("AirDrop")
|
||||
}
|
||||
.foregroundStyle(.gray)
|
||||
.font(.system(.headline, design: .rounded))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
trigger = .init()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
let picker = NSOpenPanel()
|
||||
picker.allowsMultipleSelection = true
|
||||
picker.canChooseDirectories = true
|
||||
picker.canChooseFiles = true
|
||||
picker.begin { response in
|
||||
if response == .OK {
|
||||
let drop = AirDrop(files: picker.urls)
|
||||
drop.begin()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func beginDrop(_ providers: [NSItemProvider]) {
|
||||
assert(!Thread.isMainThread)
|
||||
guard let urls = providers.interfaceConvert() else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
let drop = AirDrop(files: urls)
|
||||
drop.begin()
|
||||
}
|
||||
}
|
||||
}
|
||||
56
DynamicIsland/components/Shelf/DragDropView.swift
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
//
|
||||
// DragDropView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 2024. 10. 19..
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
class DragDropView: NSView {
|
||||
var onDragEntered: () -> Void = {}
|
||||
var onDragExited: () -> Void = {}
|
||||
var onDrop: () -> Void = {}
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
registerForDraggedTypes([.fileURL])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
||||
onDragEntered()
|
||||
return .copy
|
||||
}
|
||||
|
||||
override func draggingExited(_ sender: NSDraggingInfo?) {
|
||||
onDragExited()
|
||||
}
|
||||
|
||||
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
||||
onDrop()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
struct DragDropViewRepresentable: NSViewRepresentable {
|
||||
@Binding var isTargeted: Bool
|
||||
var onDrop: () -> Void
|
||||
|
||||
func makeNSView(context: Context) -> DragDropView {
|
||||
let view = DragDropView()
|
||||
view.onDragEntered = { isTargeted = true }
|
||||
view.onDragExited = { isTargeted = false }
|
||||
view.onDrop = onDrop
|
||||
|
||||
view.autoresizingMask = [.width, .height]
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: DragDropView, context: Context) {}
|
||||
}
|
||||
58
DynamicIsland/components/Shelf/DropItem.swift
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
//
|
||||
// TrayDrop+DropItem.swift
|
||||
// TrayDrop
|
||||
//
|
||||
// Created by 秋星桥 on 2024/7/8.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import Foundation
|
||||
import QuickLook
|
||||
|
||||
extension TrayDrop {
|
||||
struct DropItem: Identifiable, Codable, Equatable, Hashable {
|
||||
let id: UUID
|
||||
|
||||
let fileName: String
|
||||
let size: Int
|
||||
|
||||
let copiedDate: Date
|
||||
let workspacePreviewImageData: Data
|
||||
|
||||
init(url: URL) throws {
|
||||
assert(!Thread.isMainThread)
|
||||
|
||||
id = UUID()
|
||||
fileName = url.lastPathComponent
|
||||
|
||||
size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0
|
||||
copiedDate = Date()
|
||||
workspacePreviewImageData = url.snapshotPreview().pngRepresentation
|
||||
|
||||
try FileManager.default.createDirectory(
|
||||
at: storageURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try FileManager.default.copyItem(at: url, to: storageURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TrayDrop.DropItem {
|
||||
static let mainDir = "CopiedItems"
|
||||
|
||||
var storageURL: URL {
|
||||
documentsDirectory
|
||||
.appendingPathComponent(Self.mainDir)
|
||||
.appendingPathComponent(id.uuidString)
|
||||
.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
var workspacePreviewImage: NSImage {
|
||||
.init(data: workspacePreviewImageData) ?? .init()
|
||||
}
|
||||
|
||||
var shouldClean: Bool { // TODO: In the future clean if old
|
||||
return false
|
||||
}
|
||||
}
|
||||
79
DynamicIsland/components/Shelf/DropItemView.swift
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
//
|
||||
// TrayDrop+DropItemView.swift
|
||||
// NotchDrop
|
||||
//
|
||||
// Created by 秋星桥 on 2024/7/8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct DropItemView: View {
|
||||
let item: TrayDrop.DropItem
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@StateObject var tvm = TrayDrop.shared
|
||||
|
||||
@State var hover = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
VStack {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(.clear)
|
||||
.background {
|
||||
Image(nsImage: item.workspacePreviewImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
Text(item.fileName)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(hover ? .white : .gray)
|
||||
.lineLimit(2)
|
||||
.allowsTightening(true)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onDrag {
|
||||
handleOnDrag(for: item)
|
||||
}
|
||||
.frame(width: 64, height: 64)
|
||||
|
||||
if hover {
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.overlay(Image(systemName: "xmark").foregroundStyle(.black).font(.system(size: 7)).fontWeight(.semibold))
|
||||
.frame(width: spacing, height: spacing)
|
||||
.opacity(hover ? 1 : 0) // TODO: Use option key pressed to show delete
|
||||
.scaleEffect(coordinator.optionKeyPressed ? 1 : 0.5)
|
||||
.transition(.blurReplace.combined(with: .scale))
|
||||
.offset(x: spacing / 2, y: -spacing / 2)
|
||||
.onTapGesture { tvm.delete(item.id) }
|
||||
.shadow(color: .black, radius: 3)
|
||||
}
|
||||
}
|
||||
.onHover { hovering in
|
||||
withAnimation(.smooth) {
|
||||
if hovering {
|
||||
hover.toggle()
|
||||
} else {
|
||||
hover.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleOnDrag(for item: TrayDrop.DropItem) -> NSItemProvider {
|
||||
guard let itemProvider = NSItemProvider(contentsOf: item.storageURL) else {
|
||||
return NSItemProvider()
|
||||
}
|
||||
|
||||
let nameWithoutExtension = (item.fileName as NSString).deletingPathExtension
|
||||
itemProvider.suggestedName = nameWithoutExtension
|
||||
|
||||
return itemProvider
|
||||
}
|
||||
}
|
||||
61
DynamicIsland/components/Shelf/Ext+FileProvider.swift
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
//
|
||||
// Ext+FileProvider.swift
|
||||
// NotchDrop
|
||||
//
|
||||
// Created by 秋星桥 on 2024/7/8.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
extension NSItemProvider {
|
||||
private func duplicateToOurStorage(_ url: URL?) throws -> URL? {
|
||||
guard let url else { return nil }
|
||||
let temp = temporaryDirectory
|
||||
.appendingPathComponent("TemporaryDrop")
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathComponent(url.lastPathComponent)
|
||||
try? FileManager.default.createDirectory(
|
||||
at: temp.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try FileManager.default.copyItem(at: url, to: temp)
|
||||
return temp
|
||||
}
|
||||
|
||||
func convertToFilePathThatIsWhatWeThinkItWillWorkWithNotchDrop() -> URL? {
|
||||
var url: URL?
|
||||
let sem = DispatchSemaphore(value: 0)
|
||||
_ = loadObject(ofClass: URL.self) { item, _ in
|
||||
url = try? self.duplicateToOurStorage(item)
|
||||
sem.signal()
|
||||
}
|
||||
sem.wait()
|
||||
if url == nil {
|
||||
loadInPlaceFileRepresentation(
|
||||
forTypeIdentifier: UTType.data.identifier
|
||||
) { input, _, _ in
|
||||
defer { sem.signal() }
|
||||
url = try? self.duplicateToOurStorage(input)
|
||||
}
|
||||
sem.wait()
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
extension [NSItemProvider] {
|
||||
func interfaceConvert() -> [URL]? {
|
||||
let urls = compactMap { provider -> URL? in
|
||||
provider.convertToFilePathThatIsWhatWeThinkItWillWorkWithNotchDrop()
|
||||
}
|
||||
guard urls.count == count else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
NSAlert.popError(NSLocalizedString("One or more files failed to load", comment: ""))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return urls
|
||||
}
|
||||
}
|
||||
36
DynamicIsland/components/Shelf/Ext+NSAlert.swift
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//
|
||||
// Ext+NSAlert.swift
|
||||
// NotchDrop
|
||||
//
|
||||
// Created by 秋星桥 on 2024/7/9.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
extension NSAlert {
|
||||
static func popError(_ error: String) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = NSLocalizedString("Error", comment: "")
|
||||
alert.alertStyle = .critical
|
||||
alert.informativeText = error
|
||||
alert.addButton(withTitle: NSLocalizedString("OK", comment: ""))
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
static func popRestart(_ error: String, completion: @escaping () -> Void) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = NSLocalizedString("Need Restart", comment: "")
|
||||
alert.alertStyle = .critical
|
||||
alert.informativeText = error
|
||||
alert.addButton(withTitle: NSLocalizedString("Exit", comment: ""))
|
||||
alert.addButton(withTitle: NSLocalizedString("Later", comment: ""))
|
||||
let response = alert.runModal()
|
||||
if response == .alertFirstButtonReturn {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
static func popError(_ error: Error) {
|
||||
popError(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
19
DynamicIsland/components/Shelf/Ext+NSImage.swift
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
//
|
||||
// Ext+NSImage.swift
|
||||
// NotchDrop
|
||||
//
|
||||
// Created by 秋星桥 on 2024/7/8.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
|
||||
extension NSImage {
|
||||
var pngRepresentation: Data {
|
||||
guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
return .init()
|
||||
}
|
||||
let imageRep = NSBitmapImageRep(cgImage: cgImage)
|
||||
imageRep.size = size
|
||||
return imageRep.representation(using: .png, properties: [:]) ?? .init()
|
||||
}
|
||||
}
|
||||
24
DynamicIsland/components/Shelf/Ext+URL.swift
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// Ext+URL.swift
|
||||
// NotchDrop
|
||||
//
|
||||
// Created by 秋星桥 on 2024/7/8.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import Foundation
|
||||
import QuickLook
|
||||
|
||||
extension URL {
|
||||
func snapshotPreview() -> NSImage {
|
||||
if let preview = QLThumbnailImageCreate(
|
||||
kCFAllocatorDefault,
|
||||
self as CFURL,
|
||||
CGSize(width: 128, height: 128),
|
||||
nil
|
||||
)?.takeRetainedValue() {
|
||||
return NSImage(cgImage: preview, size: .zero)
|
||||
}
|
||||
return NSWorkspace.shared.icon(forFile: path)
|
||||
}
|
||||
}
|
||||
77
DynamicIsland/components/Shelf/TrayDrop.swift
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import Cocoa
|
||||
import Combine
|
||||
import Foundation
|
||||
import OrderedCollections
|
||||
|
||||
class TrayDrop: ObservableObject {
|
||||
static let shared = TrayDrop()
|
||||
|
||||
@Published var items: OrderedSet<DropItem>
|
||||
var isEmpty: Bool { items.isEmpty }
|
||||
@Published var isLoading: Int = 0
|
||||
|
||||
init(items: OrderedSet<DropItem> = .init(), isLoading: Int = 0) {
|
||||
self.items = items
|
||||
self.isLoading = isLoading
|
||||
}
|
||||
|
||||
func load(_ providers: [NSItemProvider]) {
|
||||
assert(!Thread.isMainThread)
|
||||
DispatchQueue.main.asyncAndWait { isLoading += 1 }
|
||||
|
||||
guard let urls = providers.interfaceConvert() else {
|
||||
DispatchQueue.main.asyncAndWait { isLoading -= 1 }
|
||||
print("Faield to load items")
|
||||
return
|
||||
}
|
||||
let dropItems = urls.map { url in
|
||||
try? DropItem(url: url)
|
||||
}.compactMap { $0 }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
dropItems.forEach { self.items.updateOrInsert($0, at: 0) }
|
||||
self.isLoading -= 1
|
||||
}
|
||||
print("DONE")
|
||||
}
|
||||
|
||||
func cleanExpiredFiles() {
|
||||
var inEdit = items
|
||||
let shouldCleanItems = items.filter(\.shouldClean)
|
||||
for item in shouldCleanItems {
|
||||
inEdit.remove(item)
|
||||
}
|
||||
items = inEdit
|
||||
}
|
||||
|
||||
func delete(_ item: DropItem.ID) {
|
||||
guard let item = items.first(where: { $0.id == item }) else { return }
|
||||
delete(item: item)
|
||||
}
|
||||
|
||||
private func delete(item: DropItem) {
|
||||
var inEdit = items
|
||||
|
||||
var url = item.storageURL
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
|
||||
do {
|
||||
// loops up to the main directory
|
||||
url = url.deletingLastPathComponent()
|
||||
while url.lastPathComponent != DropItem.mainDir, url != documentsDirectory {
|
||||
let contents = try FileManager.default.contentsOfDirectory(atPath: url.path)
|
||||
guard contents.isEmpty else { break }
|
||||
try FileManager.default.removeItem(at: url)
|
||||
url = url.deletingLastPathComponent()
|
||||
}
|
||||
} catch {}
|
||||
|
||||
inEdit.remove(item)
|
||||
items = inEdit
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
items.forEach { delete(item: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
30
DynamicIsland/components/Tabs/TabButton.swift
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// TabButton.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Hugo Persson on 2024-08-24.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TabButton: View {
|
||||
let label: String
|
||||
let icon: String
|
||||
let selected: Bool
|
||||
let onClick: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onClick) {
|
||||
Image(systemName: icon)
|
||||
.padding(.horizontal, 15)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TabButton(label: "Home", icon: "tray.fill", selected: true) {
|
||||
print("Tapped")
|
||||
}
|
||||
}
|
||||
55
DynamicIsland/components/Tabs/TabSelectionView.swift
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
//
|
||||
// TabSelectionView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Hugo Persson on 2024-08-25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TabModel: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
let icon: String
|
||||
let view: NotchViews
|
||||
}
|
||||
|
||||
let tabs = [
|
||||
TabModel(label: "Home", icon: "house.fill", view: .home),
|
||||
TabModel(label: "Shelf", icon: "tray.fill", view: .shelf)
|
||||
]
|
||||
|
||||
struct TabSelectionView: View {
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@Namespace var animation
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(tabs) { tab in
|
||||
TabButton(label: tab.label, icon: tab.icon, selected: coordinator.currentView == tab.view) {
|
||||
withAnimation(.smooth) {
|
||||
coordinator.currentView = tab.view
|
||||
}
|
||||
}
|
||||
.frame(height: 26)
|
||||
.foregroundStyle(tab.view == coordinator.currentView ? .white : .gray)
|
||||
.background {
|
||||
if tab.view == coordinator.currentView {
|
||||
Capsule()
|
||||
.fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear)
|
||||
.matchedGeometryEffect(id: "capsule", in: animation)
|
||||
} else {
|
||||
Capsule()
|
||||
.fill(coordinator.currentView == tab.view ? Color(nsColor: .secondarySystemFill) : Color.clear)
|
||||
.matchedGeometryEffect(id: "capsule", in: animation)
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DynamicIslandHeader().environmentObject(DynamicIslandViewModel())
|
||||
}
|
||||
92
DynamicIsland/components/TestView.swift
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//
|
||||
// TestView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 14/08/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct FluidSlider: View {
|
||||
private let color: Color = Color.white
|
||||
@State private var offset: CGFloat = 0
|
||||
var rectSize = CGSize(width: 300, height: 50)
|
||||
var rectSize2 = CGSize(width: 200, height: 18)
|
||||
var circleSize: CGFloat = 35
|
||||
@GestureState var isDragging: Bool = false
|
||||
@State var previousOffset: CGFloat = 0
|
||||
@State private var isBeating: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
slider
|
||||
.frame(width: rectSize2.width, height: circleSize)
|
||||
}
|
||||
.padding()
|
||||
.background(.black)
|
||||
}
|
||||
|
||||
private var slider: some View {
|
||||
ZStack {
|
||||
Canvas { context, size in
|
||||
context.addFilter(.alphaThreshold(min: 0.5, max: 1, color: color))
|
||||
context.addFilter(.blur(radius: 10))
|
||||
|
||||
context.drawLayer { ctx in
|
||||
if let rectangle = ctx.resolveSymbol(id: "Capsule") {
|
||||
ctx.draw(rectangle, at: CGPoint(x: size.width/2, y: size.height/2))
|
||||
}
|
||||
if let circle = ctx.resolveSymbol(id: "Circle") {
|
||||
ctx.draw(circle, at: CGPoint(x: size.width/2 - rectSize2.width/2 + circleSize/2, y: size.height/2))
|
||||
}
|
||||
}
|
||||
} symbols: {
|
||||
Capsule()
|
||||
.frame(width: rectSize2.width, height: rectSize2.height, alignment: .center)
|
||||
.tag("Capsule")
|
||||
|
||||
Circle()
|
||||
.frame(width: circleSize, height: circleSize, alignment: .center)
|
||||
.offset(x: offset)
|
||||
.animation(.spring(), value: isDragging)
|
||||
.tag("Circle")
|
||||
}
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.updating($isDragging, body: { _, state, _ in
|
||||
state = true
|
||||
})
|
||||
.onChanged({ value in
|
||||
self.offset = min(max(self.previousOffset + value.translation.width, 0), rectSize2.width - circleSize)
|
||||
})
|
||||
.onEnded({ value in
|
||||
self.previousOffset = self.offset
|
||||
})
|
||||
)
|
||||
Circle()
|
||||
.fill(Color.black)
|
||||
.frame(width: circleSize * 0.6)
|
||||
.overlay {
|
||||
Image(systemName: "speaker.wave.2.fill")
|
||||
.imageScale(.small)
|
||||
}
|
||||
.offset(x: (-rectSize2.width/2) + (circleSize/2))
|
||||
.offset(x: offset)
|
||||
.animation(.spring(), value: isDragging)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var animation: Animation {
|
||||
.spring(response: 0.5, dampingFraction: 0.6, blendDuration: 0.5)
|
||||
}
|
||||
|
||||
private var percentage: Int {
|
||||
Int((offset) / (rectSize.width - circleSize) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
FluidSlider()
|
||||
}
|
||||
74
DynamicIsland/components/Tips/TipStore.swift
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
//
|
||||
// TipStore.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 15/09/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import TipKit
|
||||
|
||||
struct HUDsTip: Tip {
|
||||
var title: Text {
|
||||
Text("Enhance your experience with HUDs")
|
||||
}
|
||||
|
||||
|
||||
var message: Text? {
|
||||
Text("Unlock advanced features and improve your experience. Upgrade now for more customizations!")
|
||||
}
|
||||
|
||||
|
||||
var image: Image? {
|
||||
AppIcon(for: "dynamicisland.DynamicIsland")
|
||||
}
|
||||
|
||||
var actions: [Action] {
|
||||
Action {
|
||||
Text("More")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CBTip: Tip {
|
||||
var title: Text {
|
||||
Text("Boost your productivity with Clipboard Manager")
|
||||
}
|
||||
|
||||
|
||||
var message: Text? {
|
||||
Text("Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!")
|
||||
}
|
||||
|
||||
|
||||
var image: Image? {
|
||||
AppIcon(for: "dynamicisland.DynamicIsland")
|
||||
}
|
||||
|
||||
var actions: [Action] {
|
||||
Action {
|
||||
Text("More")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TipsView: View {
|
||||
var hudTip = HUDsTip()
|
||||
var cbTip = CBTip()
|
||||
var body: some View {
|
||||
VStack {
|
||||
TipView(hudTip)
|
||||
TipView(cbTip)
|
||||
}
|
||||
.task {
|
||||
try? Tips.configure([
|
||||
.displayFrequency(.immediate),
|
||||
.datastoreLocation(.applicationDefault)
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TipsView()
|
||||
}
|
||||
122
DynamicIsland/components/Webcam/WebcamView.swift
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
//
|
||||
// WebcamView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 19/08/24.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
import SwiftUI
|
||||
|
||||
struct CameraPreviewView: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@ObservedObject var webcamManager: WebcamManager
|
||||
|
||||
// Track if authorization request is in progress to avoid multiple requests
|
||||
@State private var isRequestingAuthorization: Bool = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
if let previewLayer = webcamManager.previewLayer {
|
||||
CameraPreviewLayerView(previewLayer: previewLayer)
|
||||
.scaleEffect(x: -1, y: 1)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Defaults[.mirrorShape] == .rectangle ? !Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.closed : MusicPlayerImageSizes.cornerRadiusInset.opened : 100))
|
||||
.frame(width: geometry.size.width, height: geometry.size.width)
|
||||
.opacity(webcamManager.isSessionRunning ? 1 : 0)
|
||||
}
|
||||
|
||||
if !webcamManager.isSessionRunning {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: Defaults[.mirrorShape] == .rectangle ? !Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.closed : 12 : 100)
|
||||
.fill(Color(red: 20/255, green: 20/255, blue: 20/255))
|
||||
.strokeBorder(.white.opacity(0.04), lineWidth: 1)
|
||||
.frame(width: geometry.size.width, height: geometry.size.width)
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: webcamManager.authorizationStatus == .denied ? "exclamationmark.triangle" : "web.camera")
|
||||
.foregroundStyle(.gray)
|
||||
.font(.system(size: geometry.size.width/3.5))
|
||||
Text(webcamManager.authorizationStatus == .denied ? "Access Denied" : "Mirror")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
handleCameraTap()
|
||||
}
|
||||
.onDisappear {
|
||||
webcamManager.stopSession()
|
||||
}
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
}
|
||||
|
||||
private func handleCameraTap() {
|
||||
if isRequestingAuthorization {
|
||||
return // Prevent multiple authorization requests
|
||||
}
|
||||
|
||||
switch webcamManager.authorizationStatus {
|
||||
case .authorized:
|
||||
if webcamManager.isSessionRunning {
|
||||
webcamManager.stopSession()
|
||||
} else if webcamManager.cameraAvailable {
|
||||
webcamManager.startSession()
|
||||
}
|
||||
case .denied, .restricted:
|
||||
DispatchQueue.main.async {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Camera Access Required"
|
||||
alert.informativeText = "Please allow camera access in System Settings to use the mirror feature."
|
||||
alert.addButton(withTitle: "Open Settings")
|
||||
alert.addButton(withTitle: "Cancel")
|
||||
|
||||
if alert.runModal() == .alertFirstButtonReturn {
|
||||
if let settingsURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera") {
|
||||
NSWorkspace.shared.open(settingsURL)
|
||||
}
|
||||
}
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
NSApp.deactivate()
|
||||
}
|
||||
case .notDetermined:
|
||||
isRequestingAuthorization = true
|
||||
webcamManager.checkAndRequestVideoAuthorization()
|
||||
// Reset the request flag after a reasonable delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
isRequestingAuthorization = false
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CameraPreviewLayerView: NSViewRepresentable {
|
||||
let previewLayer: AVCaptureVideoPreviewLayer
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView(frame: .zero)
|
||||
previewLayer.frame = view.bounds
|
||||
previewLayer.videoGravity = .resizeAspectFill
|
||||
view.layer = previewLayer
|
||||
view.wantsLayer = true
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
previewLayer.frame = nsView.bounds
|
||||
CATransaction.commit()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CameraPreviewView(webcamManager: WebcamManager())
|
||||
}
|
||||
35
DynamicIsland/components/WhatsNewView.swift
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// WhatsNewView.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 09/08/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct WhatsNewView: View {
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("What's New")
|
||||
.font(.largeTitle)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("• New feature 1")
|
||||
Text("• Improved performance")
|
||||
Text("• Bug fixes")
|
||||
}
|
||||
|
||||
Button("Got it!") {
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
.frame(width: 300, height: 200)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WhatsNewView(isPresented: .constant(true))
|
||||
}
|
||||
69
DynamicIsland/enums/generic.swift
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
//
|
||||
// generic.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 04/08/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Defaults
|
||||
|
||||
public enum Style {
|
||||
case notch
|
||||
case floating
|
||||
}
|
||||
|
||||
public enum ContentType: Int, Codable, Hashable, Equatable {
|
||||
case normal
|
||||
case menu
|
||||
case settings
|
||||
}
|
||||
|
||||
public enum NotchState {
|
||||
case closed
|
||||
case open
|
||||
}
|
||||
|
||||
public enum NotchViews {
|
||||
case home
|
||||
case shelf
|
||||
}
|
||||
|
||||
enum SettingsEnum {
|
||||
case general
|
||||
case about
|
||||
case charge
|
||||
case download
|
||||
case mediaPlayback
|
||||
case hud
|
||||
case shelf
|
||||
case extensions
|
||||
}
|
||||
|
||||
enum DownloadIndicatorStyle: String, Defaults.Serializable {
|
||||
case progress = "Progress"
|
||||
case percentage = "Percentage"
|
||||
}
|
||||
|
||||
enum DownloadIconStyle: String, Defaults.Serializable {
|
||||
case onlyAppIcon = "Only app icon"
|
||||
case onlyIcon = "Only download icon"
|
||||
case iconAndAppIcon = "Icon and app icon"
|
||||
}
|
||||
|
||||
enum MirrorShapeEnum: String, Defaults.Serializable {
|
||||
case rectangle = "Rectangular"
|
||||
case circle = "Circular"
|
||||
}
|
||||
|
||||
enum WindowHeightMode: String, Defaults.Serializable {
|
||||
case matchMenuBar = "Match menubar height"
|
||||
case matchRealNotchSize = "Match real notch height"
|
||||
case custom = "Custom height"
|
||||
}
|
||||
|
||||
enum SliderColorEnum: String, CaseIterable, Defaults.Serializable {
|
||||
case white = "White"
|
||||
case albumArt = "Match album art"
|
||||
case accent = "Accent color"
|
||||
}
|
||||
30
DynamicIsland/extensions/ActionBar.swift
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// ActionBar.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 15/09/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func actionBar<Content: View>(padding: CGFloat = 10, @ViewBuilder content: () -> Content) -> some View {
|
||||
self
|
||||
.padding(.bottom, 24)
|
||||
.overlay(alignment: .bottom) {
|
||||
VStack(spacing: -1) {
|
||||
Divider()
|
||||
HStack(spacing: 0) {
|
||||
content()
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
.frame(height: 16)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(height: 24)
|
||||
.background(.separator)
|
||||
}
|
||||
}
|
||||
}
|
||||
57
DynamicIsland/extensions/BundleInfos.swift
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//
|
||||
// BundleInfos.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 08/08/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension Bundle {
|
||||
var releaseVersionNumber: String? {
|
||||
return infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
}
|
||||
var buildVersionNumber: String? {
|
||||
return infoDictionary?["CFBundleVersion"] as? String
|
||||
}
|
||||
var releaseVersionNumberPretty: String {
|
||||
return "v\(releaseVersionNumber ?? "1.0.0")"
|
||||
}
|
||||
|
||||
var iconFileName: String? {
|
||||
guard let icons = infoDictionary?["CFBundleIcons"] as? [String: Any],
|
||||
let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any],
|
||||
let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String],
|
||||
let iconFileName = iconFiles.last
|
||||
else { return nil }
|
||||
return iconFileName
|
||||
}
|
||||
}
|
||||
|
||||
struct BundleAppIcon: View {
|
||||
var body: some View {
|
||||
Bundle.main.iconFileName
|
||||
.flatMap { NSImage(named: $0) }
|
||||
.map { Image(nsImage: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
func isNewVersion() -> Bool {
|
||||
let defaults = UserDefaults.standard
|
||||
let currentVersion = Bundle.main.releaseVersionNumber ?? "1.0"
|
||||
let savedVersion = defaults.string(forKey: "LastVersionRun") ?? ""
|
||||
|
||||
if currentVersion != savedVersion {
|
||||
defaults.set(currentVersion, forKey: "LastVersionRun")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isExtensionRunning(_ bundleID: String) -> Bool {
|
||||
if let _ = NSWorkspace.shared.runningApplications.first(where: {$0.bundleIdentifier == bundleID}) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
35
DynamicIsland/extensions/Button+Bouncing.swift
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// Button+Bouncing.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Harsh Vardhan Goswami on 19/08/24.
|
||||
//
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct BouncingButtonStyle: ButtonStyle {
|
||||
let vm: DynamicIslandViewModel
|
||||
@State private var isPressed = false
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: Defaults[.cornerRadiusScaling] ? 10 : MusicPlayerImageSizes.cornerRadiusInset.closed)
|
||||
.fill(Color(red: 20/255, green: 20/255, blue: 20/255))
|
||||
.strokeBorder(.white.opacity(0.04), lineWidth: 1)
|
||||
)
|
||||
.scaleEffect(isPressed ? 0.9 : 1.0)
|
||||
.onChange(of: configuration.isPressed) { _, _ in
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.3, blendDuration: 0.3)) {
|
||||
isPressed.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Button {
|
||||
func bouncingStyle(vm: DynamicIslandViewModel) -> some View {
|
||||
self.buttonStyle(BouncingButtonStyle(vm: vm))
|
||||
}
|
||||
}
|
||||
18
DynamicIsland/extensions/ConditionalModifier.swift
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
//
|
||||
// ConditionalModifier.swift
|
||||
// boringNotch
|
||||
//
|
||||
// Created by Richard Kunkli on 20/08/2024.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
@ViewBuilder func conditionalModifier<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||||
if condition {
|
||||
transform(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||