├── .github └── workflows │ └── swift.yml ├── .gitignore ├── NativeTwitch.xcodeproj ├── project.pbxproj └── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── NativeTwitch ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── icon_128x128.png │ │ ├── icon_128x128@2x.png │ │ ├── icon_16x16.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_256x256.png │ │ ├── icon_256x256@2x.png │ │ ├── icon_32x32.png │ │ ├── icon_32x32@2x.png │ │ ├── icon_512x512.png │ │ └── icon_512x512@2x.png │ ├── AppIconImage.imageset │ │ ├── Contents.json │ │ ├── icon.png │ │ ├── icon@2x.png │ │ └── icon@3x.png │ ├── Contents.json │ ├── exampleStream.imageset │ │ ├── CleanShot 2023-12-15 at 19.52.34.png │ │ └── Contents.json │ ├── exampleStreamer.imageset │ │ ├── Contents.json │ │ └── exampleStreamer@2x.png │ ├── exampleUser.imageset │ │ ├── Contents.json │ │ └── exampleUser@2x.png │ ├── menuBarIcon.imageset │ │ ├── Contents.json │ │ └── menuIcon.svg │ ├── transparentIcon.imageset │ │ ├── Contents.json │ │ ├── transparentIcon.png │ │ ├── transparentIcon@2x.png │ │ └── transparentIcon@3x.png │ ├── twitchColor.colorset │ │ └── Contents.json │ └── twitchLogo.imageset │ │ ├── Contents.json │ │ └── twitchLogo@2x.png ├── ContentView.swift ├── Extensions │ ├── Bundle+Extensions.swift │ ├── Double+Extension.swift │ ├── LinearGradient+Extensions.swift │ ├── Logger+Extensions.swift │ ├── String+Extensions.swift │ ├── View+Extension.swift │ └── VisualEffectView.swift ├── Helpers │ ├── AppDelegate.swift │ └── KeychainHelper.swift ├── Info.plist ├── Models │ ├── AuthModel.swift │ ├── Constants.swift │ ├── OauthValidate.swift │ └── Streams.swift ├── NativeTwitch.entitlements ├── NativeTwitchApp.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Shaders │ ├── AnimatedGradientFill.metal │ ├── GenerativePreview.swift │ ├── LightGrid.metal │ └── Sinebow.metal ├── ViewModels │ ├── TwitchDeviceAuth.swift │ └── TwitchVM.swift ├── ViewModifiers │ ├── CleanTextFieldStyle.swift │ ├── LongButtonModifier.swift │ └── TransitionModifier.swift └── Views │ ├── AboutView.swift │ ├── LoginView.swift │ ├── SingleStreamRow.swift │ ├── StreamsView.swift │ └── Subviews │ └── BadgeView.swift └── README.md /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | jobs: 9 | build: 10 | runs-on: macos-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Build 14 | run: swift package init --type executable 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ---- macOS ---- 2 | # General 3 | .DS_Store 4 | .AppleDouble 5 | .LSOverride 6 | 7 | # Icon must end with two \r 8 | Icon 9 | 10 | 11 | # Thumbnails 12 | ._* 13 | 14 | # Files that might appear in the root of a volume 15 | .DocumentRevisions-V100 16 | .fseventsd 17 | .Spotlight-V100 18 | .TemporaryItems 19 | .Trashes 20 | .VolumeIcon.icns 21 | .com.apple.timemachine.donotpresent 22 | 23 | # Directories potentially created on remote AFP share 24 | .AppleDB 25 | .AppleDesktop 26 | Network Trash Folder 27 | Temporary Items 28 | .apdisk 29 | 30 | # ---- Swift ---- 31 | # Xcode 32 | # 33 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 34 | 35 | ## User settings 36 | xcuserdata/ 37 | 38 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 39 | *.xcscmblueprint 40 | *.xccheckout 41 | 42 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 43 | build/ 44 | DerivedData/ 45 | *.moved-aside 46 | *.pbxuser 47 | !default.pbxuser 48 | *.mode1v3 49 | !default.mode1v3 50 | *.mode2v3 51 | !default.mode2v3 52 | *.perspectivev3 53 | !default.perspectivev3 54 | 55 | ## Obj-C/Swift specific 56 | *.hmap 57 | 58 | ## App packaging 59 | *.ipa 60 | *.dSYM.zip 61 | *.dSYM 62 | 63 | ## Playgrounds 64 | timeline.xctimeline 65 | playground.xcworkspace 66 | 67 | # Swift Package Manager 68 | # 69 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 70 | # Packages/ 71 | # Package.pins 72 | # Package.resolved 73 | # *.xcodeproj 74 | # 75 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 76 | # hence it is not needed unless you have added a package configuration file to your project 77 | # .swiftpm 78 | 79 | .build/ 80 | 81 | # CocoaPods 82 | # 83 | # We recommend against adding the Pods directory to your .gitignore. However 84 | # you should judge for yourself, the pros and cons are mentioned at: 85 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 86 | # 87 | # Pods/ 88 | # 89 | # Add this line if you want to avoid checking in source code from the Xcode workspace 90 | # *.xcworkspace 91 | 92 | # Carthage 93 | # 94 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 95 | # Carthage/Checkouts 96 | 97 | Carthage/Build/ 98 | 99 | # Accio dependency management 100 | Dependencies/ 101 | .accio/ 102 | 103 | # fastlane 104 | # 105 | # It is recommended to not store the screenshots in the git repo. 106 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 107 | # For more information about the recommended setup visit: 108 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 109 | 110 | fastlane/report.xml 111 | fastlane/Preview.html 112 | fastlane/screenshots/**/*.png 113 | fastlane/test_output 114 | 115 | # Code Injection 116 | # 117 | # After new code Injection tools there's a generated folder /iOSInjectionProject 118 | # https://github.com/johnno1962/injectionforxcode 119 | 120 | iOSInjectionProject/ 121 | 122 | -------------------------------------------------------------------------------- /NativeTwitch.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 450871C62B291C4B00F938A2 /* NativeTwitchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450871C52B291C4B00F938A2 /* NativeTwitchApp.swift */; }; 11 | 450871C82B291C4B00F938A2 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450871C72B291C4B00F938A2 /* ContentView.swift */; }; 12 | 450871CA2B291C4C00F938A2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 450871C92B291C4C00F938A2 /* Assets.xcassets */; }; 13 | 450871CD2B291C4C00F938A2 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 450871CC2B291C4C00F938A2 /* Preview Assets.xcassets */; }; 14 | 450871DC2B291C8100F938A2 /* Logger+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450871DA2B291C7500F938A2 /* Logger+Extensions.swift */; }; 15 | 450871DD2B291C8100F938A2 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450871DB2B291C7500F938A2 /* View+Extension.swift */; }; 16 | 450871DE2B291C8100F938A2 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450871D92B291C7500F938A2 /* Double+Extension.swift */; }; 17 | 450871DF2B291C8100F938A2 /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450871D82B291C7500F938A2 /* Bundle+Extensions.swift */; }; 18 | 450871E12B291C8800F938A2 /* VisualEffectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450871E02B291C8800F938A2 /* VisualEffectView.swift */; }; 19 | 450871E42B291CBA00F938A2 /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450871E32B291CB900F938A2 /* KeychainHelper.swift */; }; 20 | 450871E62B291CDA00F938A2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450871E52B291CDA00F938A2 /* AppDelegate.swift */; }; 21 | 450871E82B291CE400F938A2 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450871E72B291CDF00F938A2 /* AboutView.swift */; }; 22 | 4571283C2B2AA90700838150 /* Streams.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4571283B2B2AA90700838150 /* Streams.swift */; }; 23 | 457128402B2AAB5C00838150 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4571283F2B2AAB5C00838150 /* Constants.swift */; }; 24 | 457128422B2AAB6F00838150 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457128412B2AAB6F00838150 /* String+Extensions.swift */; }; 25 | 457128442B2AAE9A00838150 /* TwitchVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457128432B2AAE9A00838150 /* TwitchVM.swift */; }; 26 | 457128462B2AAFC700838150 /* AuthModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457128452B2AAFC700838150 /* AuthModel.swift */; }; 27 | 457128482B2AB2EA00838150 /* OauthValidate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 457128472B2AB2EA00838150 /* OauthValidate.swift */; }; 28 | 45A401722B2E12F900CFA6CC /* BadgeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45A401712B2E12F900CFA6CC /* BadgeView.swift */; }; 29 | 45AE499D2B3193DB00B6CBEF /* TwitchDeviceAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45AE499C2B3193DB00B6CBEF /* TwitchDeviceAuth.swift */; }; 30 | 45B914182B2D17D500B8D3D1 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B914172B2D17D500B8D3D1 /* LoginView.swift */; }; 31 | 45B9141B2B2D1BFF00B8D3D1 /* LongButtonModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B9141A2B2D1BFF00B8D3D1 /* LongButtonModifier.swift */; }; 32 | 45B9141D2B2D1D0500B8D3D1 /* CleanTextFieldStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B9141C2B2D1D0400B8D3D1 /* CleanTextFieldStyle.swift */; }; 33 | 45B914272B2D29CF00B8D3D1 /* StreamsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B914262B2D29CF00B8D3D1 /* StreamsView.swift */; }; 34 | 45B914292B2D308500B8D3D1 /* SingleStreamRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B914282B2D308500B8D3D1 /* SingleStreamRow.swift */; }; 35 | 45B9142B2B2D36B800B8D3D1 /* LinearGradient+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B9142A2B2D36B800B8D3D1 /* LinearGradient+Extensions.swift */; }; 36 | 45B9142D2B2D375200B8D3D1 /* TransitionModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B9142C2B2D375200B8D3D1 /* TransitionModifier.swift */; }; 37 | 45B914392B2D3C6B00B8D3D1 /* GenerativePreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45B914382B2D3C5500B8D3D1 /* GenerativePreview.swift */; }; 38 | 45B9143A2B2D3C7600B8D3D1 /* AnimatedGradientFill.metal in Sources */ = {isa = PBXBuildFile; fileRef = 45B914362B2D3C5500B8D3D1 /* AnimatedGradientFill.metal */; }; 39 | 45B9143B2B2D3C7600B8D3D1 /* Sinebow.metal in Sources */ = {isa = PBXBuildFile; fileRef = 45B914352B2D3C5500B8D3D1 /* Sinebow.metal */; }; 40 | 45B9143C2B2D3C7900B8D3D1 /* LightGrid.metal in Sources */ = {isa = PBXBuildFile; fileRef = 45B914372B2D3C5500B8D3D1 /* LightGrid.metal */; }; 41 | /* End PBXBuildFile section */ 42 | 43 | /* Begin PBXFileReference section */ 44 | 450871C22B291C4B00F938A2 /* NativeTwitch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NativeTwitch.app; sourceTree = BUILT_PRODUCTS_DIR; }; 45 | 450871C52B291C4B00F938A2 /* NativeTwitchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTwitchApp.swift; sourceTree = ""; }; 46 | 450871C72B291C4B00F938A2 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 47 | 450871C92B291C4C00F938A2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 48 | 450871CC2B291C4C00F938A2 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 49 | 450871CE2B291C4C00F938A2 /* NativeTwitch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NativeTwitch.entitlements; sourceTree = ""; }; 50 | 450871D82B291C7500F938A2 /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; 51 | 450871D92B291C7500F938A2 /* Double+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; 52 | 450871DA2B291C7500F938A2 /* Logger+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+Extensions.swift"; sourceTree = ""; }; 53 | 450871DB2B291C7500F938A2 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; 54 | 450871E02B291C8800F938A2 /* VisualEffectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectView.swift; sourceTree = ""; }; 55 | 450871E32B291CB900F938A2 /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; 56 | 450871E52B291CDA00F938A2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 57 | 450871E72B291CDF00F938A2 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 58 | 4571283B2B2AA90700838150 /* Streams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Streams.swift; sourceTree = ""; }; 59 | 4571283F2B2AAB5C00838150 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 60 | 457128412B2AAB6F00838150 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; 61 | 457128432B2AAE9A00838150 /* TwitchVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitchVM.swift; sourceTree = ""; }; 62 | 457128452B2AAFC700838150 /* AuthModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthModel.swift; sourceTree = ""; }; 63 | 457128472B2AB2EA00838150 /* OauthValidate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OauthValidate.swift; sourceTree = ""; }; 64 | 45A401712B2E12F900CFA6CC /* BadgeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeView.swift; sourceTree = ""; }; 65 | 45AE499B2B317FE600B6CBEF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 66 | 45AE499C2B3193DB00B6CBEF /* TwitchDeviceAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwitchDeviceAuth.swift; sourceTree = ""; }; 67 | 45B914172B2D17D500B8D3D1 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 68 | 45B9141A2B2D1BFF00B8D3D1 /* LongButtonModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongButtonModifier.swift; sourceTree = ""; }; 69 | 45B9141C2B2D1D0400B8D3D1 /* CleanTextFieldStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CleanTextFieldStyle.swift; sourceTree = ""; }; 70 | 45B914262B2D29CF00B8D3D1 /* StreamsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamsView.swift; sourceTree = ""; }; 71 | 45B914282B2D308500B8D3D1 /* SingleStreamRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStreamRow.swift; sourceTree = ""; }; 72 | 45B9142A2B2D36B800B8D3D1 /* LinearGradient+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LinearGradient+Extensions.swift"; sourceTree = ""; }; 73 | 45B9142C2B2D375200B8D3D1 /* TransitionModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitionModifier.swift; sourceTree = ""; }; 74 | 45B914352B2D3C5500B8D3D1 /* Sinebow.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = Sinebow.metal; sourceTree = ""; }; 75 | 45B914362B2D3C5500B8D3D1 /* AnimatedGradientFill.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = AnimatedGradientFill.metal; sourceTree = ""; }; 76 | 45B914372B2D3C5500B8D3D1 /* LightGrid.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = LightGrid.metal; sourceTree = ""; }; 77 | 45B914382B2D3C5500B8D3D1 /* GenerativePreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerativePreview.swift; sourceTree = ""; }; 78 | /* End PBXFileReference section */ 79 | 80 | /* Begin PBXFrameworksBuildPhase section */ 81 | 450871BF2B291C4B00F938A2 /* Frameworks */ = { 82 | isa = PBXFrameworksBuildPhase; 83 | buildActionMask = 2147483647; 84 | files = ( 85 | ); 86 | runOnlyForDeploymentPostprocessing = 0; 87 | }; 88 | /* End PBXFrameworksBuildPhase section */ 89 | 90 | /* Begin PBXGroup section */ 91 | 450871B92B291C4B00F938A2 = { 92 | isa = PBXGroup; 93 | children = ( 94 | 450871C42B291C4B00F938A2 /* NativeTwitch */, 95 | 450871C32B291C4B00F938A2 /* Products */, 96 | ); 97 | sourceTree = ""; 98 | }; 99 | 450871C32B291C4B00F938A2 /* Products */ = { 100 | isa = PBXGroup; 101 | children = ( 102 | 450871C22B291C4B00F938A2 /* NativeTwitch.app */, 103 | ); 104 | name = Products; 105 | sourceTree = ""; 106 | }; 107 | 450871C42B291C4B00F938A2 /* NativeTwitch */ = { 108 | isa = PBXGroup; 109 | children = ( 110 | 45AE499B2B317FE600B6CBEF /* Info.plist */, 111 | 450871C52B291C4B00F938A2 /* NativeTwitchApp.swift */, 112 | 450871C72B291C4B00F938A2 /* ContentView.swift */, 113 | 450871D52B291C5500F938A2 /* Views */, 114 | 450871D72B291C5D00F938A2 /* ViewModels */, 115 | 450871D62B291C5800F938A2 /* Models */, 116 | 450871E22B291CAE00F938A2 /* Helpers */, 117 | 450871D42B291C5000F938A2 /* Extensions */, 118 | 45B914192B2D1BEB00B8D3D1 /* ViewModifiers */, 119 | 45B914342B2D3C5500B8D3D1 /* Shaders */, 120 | 450871C92B291C4C00F938A2 /* Assets.xcassets */, 121 | 450871CE2B291C4C00F938A2 /* NativeTwitch.entitlements */, 122 | 450871CB2B291C4C00F938A2 /* Preview Content */, 123 | ); 124 | path = NativeTwitch; 125 | sourceTree = ""; 126 | }; 127 | 450871CB2B291C4C00F938A2 /* Preview Content */ = { 128 | isa = PBXGroup; 129 | children = ( 130 | 450871CC2B291C4C00F938A2 /* Preview Assets.xcassets */, 131 | ); 132 | path = "Preview Content"; 133 | sourceTree = ""; 134 | }; 135 | 450871D42B291C5000F938A2 /* Extensions */ = { 136 | isa = PBXGroup; 137 | children = ( 138 | 450871D82B291C7500F938A2 /* Bundle+Extensions.swift */, 139 | 450871D92B291C7500F938A2 /* Double+Extension.swift */, 140 | 450871DA2B291C7500F938A2 /* Logger+Extensions.swift */, 141 | 450871DB2B291C7500F938A2 /* View+Extension.swift */, 142 | 450871E02B291C8800F938A2 /* VisualEffectView.swift */, 143 | 457128412B2AAB6F00838150 /* String+Extensions.swift */, 144 | 45B9142A2B2D36B800B8D3D1 /* LinearGradient+Extensions.swift */, 145 | ); 146 | path = Extensions; 147 | sourceTree = ""; 148 | }; 149 | 450871D52B291C5500F938A2 /* Views */ = { 150 | isa = PBXGroup; 151 | children = ( 152 | 45B914232B2D27AC00B8D3D1 /* Subviews */, 153 | 45B914262B2D29CF00B8D3D1 /* StreamsView.swift */, 154 | 45B914282B2D308500B8D3D1 /* SingleStreamRow.swift */, 155 | 45B914172B2D17D500B8D3D1 /* LoginView.swift */, 156 | 450871E72B291CDF00F938A2 /* AboutView.swift */, 157 | ); 158 | path = Views; 159 | sourceTree = ""; 160 | }; 161 | 450871D62B291C5800F938A2 /* Models */ = { 162 | isa = PBXGroup; 163 | children = ( 164 | 4571283B2B2AA90700838150 /* Streams.swift */, 165 | 4571283F2B2AAB5C00838150 /* Constants.swift */, 166 | 457128452B2AAFC700838150 /* AuthModel.swift */, 167 | 457128472B2AB2EA00838150 /* OauthValidate.swift */, 168 | ); 169 | path = Models; 170 | sourceTree = ""; 171 | }; 172 | 450871D72B291C5D00F938A2 /* ViewModels */ = { 173 | isa = PBXGroup; 174 | children = ( 175 | 457128432B2AAE9A00838150 /* TwitchVM.swift */, 176 | 45AE499C2B3193DB00B6CBEF /* TwitchDeviceAuth.swift */, 177 | ); 178 | path = ViewModels; 179 | sourceTree = ""; 180 | }; 181 | 450871E22B291CAE00F938A2 /* Helpers */ = { 182 | isa = PBXGroup; 183 | children = ( 184 | 450871E52B291CDA00F938A2 /* AppDelegate.swift */, 185 | 450871E32B291CB900F938A2 /* KeychainHelper.swift */, 186 | ); 187 | path = Helpers; 188 | sourceTree = ""; 189 | }; 190 | 45B914192B2D1BEB00B8D3D1 /* ViewModifiers */ = { 191 | isa = PBXGroup; 192 | children = ( 193 | 45B9141A2B2D1BFF00B8D3D1 /* LongButtonModifier.swift */, 194 | 45B9141C2B2D1D0400B8D3D1 /* CleanTextFieldStyle.swift */, 195 | 45B9142C2B2D375200B8D3D1 /* TransitionModifier.swift */, 196 | ); 197 | path = ViewModifiers; 198 | sourceTree = ""; 199 | }; 200 | 45B914232B2D27AC00B8D3D1 /* Subviews */ = { 201 | isa = PBXGroup; 202 | children = ( 203 | 45A401712B2E12F900CFA6CC /* BadgeView.swift */, 204 | ); 205 | path = Subviews; 206 | sourceTree = ""; 207 | }; 208 | 45B914342B2D3C5500B8D3D1 /* Shaders */ = { 209 | isa = PBXGroup; 210 | children = ( 211 | 45B914352B2D3C5500B8D3D1 /* Sinebow.metal */, 212 | 45B914362B2D3C5500B8D3D1 /* AnimatedGradientFill.metal */, 213 | 45B914372B2D3C5500B8D3D1 /* LightGrid.metal */, 214 | 45B914382B2D3C5500B8D3D1 /* GenerativePreview.swift */, 215 | ); 216 | path = Shaders; 217 | sourceTree = ""; 218 | }; 219 | /* End PBXGroup section */ 220 | 221 | /* Begin PBXNativeTarget section */ 222 | 450871C12B291C4B00F938A2 /* NativeTwitch */ = { 223 | isa = PBXNativeTarget; 224 | buildConfigurationList = 450871D12B291C4C00F938A2 /* Build configuration list for PBXNativeTarget "NativeTwitch" */; 225 | buildPhases = ( 226 | 450871BE2B291C4B00F938A2 /* Sources */, 227 | 450871BF2B291C4B00F938A2 /* Frameworks */, 228 | 450871C02B291C4B00F938A2 /* Resources */, 229 | ); 230 | buildRules = ( 231 | ); 232 | dependencies = ( 233 | ); 234 | name = NativeTwitch; 235 | packageProductDependencies = ( 236 | ); 237 | productName = NativeTwitch; 238 | productReference = 450871C22B291C4B00F938A2 /* NativeTwitch.app */; 239 | productType = "com.apple.product-type.application"; 240 | }; 241 | /* End PBXNativeTarget section */ 242 | 243 | /* Begin PBXProject section */ 244 | 450871BA2B291C4B00F938A2 /* Project object */ = { 245 | isa = PBXProject; 246 | attributes = { 247 | BuildIndependentTargetsInParallel = 1; 248 | LastSwiftUpdateCheck = 1510; 249 | LastUpgradeCheck = 1510; 250 | TargetAttributes = { 251 | 450871C12B291C4B00F938A2 = { 252 | CreatedOnToolsVersion = 15.1; 253 | }; 254 | }; 255 | }; 256 | buildConfigurationList = 450871BD2B291C4B00F938A2 /* Build configuration list for PBXProject "NativeTwitch" */; 257 | compatibilityVersion = "Xcode 14.0"; 258 | developmentRegion = en; 259 | hasScannedForEncodings = 0; 260 | knownRegions = ( 261 | en, 262 | Base, 263 | ); 264 | mainGroup = 450871B92B291C4B00F938A2; 265 | packageReferences = ( 266 | ); 267 | productRefGroup = 450871C32B291C4B00F938A2 /* Products */; 268 | projectDirPath = ""; 269 | projectRoot = ""; 270 | targets = ( 271 | 450871C12B291C4B00F938A2 /* NativeTwitch */, 272 | ); 273 | }; 274 | /* End PBXProject section */ 275 | 276 | /* Begin PBXResourcesBuildPhase section */ 277 | 450871C02B291C4B00F938A2 /* Resources */ = { 278 | isa = PBXResourcesBuildPhase; 279 | buildActionMask = 2147483647; 280 | files = ( 281 | 450871CD2B291C4C00F938A2 /* Preview Assets.xcassets in Resources */, 282 | 450871CA2B291C4C00F938A2 /* Assets.xcassets in Resources */, 283 | ); 284 | runOnlyForDeploymentPostprocessing = 0; 285 | }; 286 | /* End PBXResourcesBuildPhase section */ 287 | 288 | /* Begin PBXSourcesBuildPhase section */ 289 | 450871BE2B291C4B00F938A2 /* Sources */ = { 290 | isa = PBXSourcesBuildPhase; 291 | buildActionMask = 2147483647; 292 | files = ( 293 | 45B9141B2B2D1BFF00B8D3D1 /* LongButtonModifier.swift in Sources */, 294 | 45B9141D2B2D1D0500B8D3D1 /* CleanTextFieldStyle.swift in Sources */, 295 | 450871DC2B291C8100F938A2 /* Logger+Extensions.swift in Sources */, 296 | 450871C82B291C4B00F938A2 /* ContentView.swift in Sources */, 297 | 450871E12B291C8800F938A2 /* VisualEffectView.swift in Sources */, 298 | 450871E62B291CDA00F938A2 /* AppDelegate.swift in Sources */, 299 | 45B9143C2B2D3C7900B8D3D1 /* LightGrid.metal in Sources */, 300 | 45B9143A2B2D3C7600B8D3D1 /* AnimatedGradientFill.metal in Sources */, 301 | 45B914272B2D29CF00B8D3D1 /* StreamsView.swift in Sources */, 302 | 45B914292B2D308500B8D3D1 /* SingleStreamRow.swift in Sources */, 303 | 45B9143B2B2D3C7600B8D3D1 /* Sinebow.metal in Sources */, 304 | 457128442B2AAE9A00838150 /* TwitchVM.swift in Sources */, 305 | 450871E82B291CE400F938A2 /* AboutView.swift in Sources */, 306 | 45A401722B2E12F900CFA6CC /* BadgeView.swift in Sources */, 307 | 450871E42B291CBA00F938A2 /* KeychainHelper.swift in Sources */, 308 | 457128482B2AB2EA00838150 /* OauthValidate.swift in Sources */, 309 | 4571283C2B2AA90700838150 /* Streams.swift in Sources */, 310 | 45B914392B2D3C6B00B8D3D1 /* GenerativePreview.swift in Sources */, 311 | 45B9142B2B2D36B800B8D3D1 /* LinearGradient+Extensions.swift in Sources */, 312 | 450871DD2B291C8100F938A2 /* View+Extension.swift in Sources */, 313 | 45B914182B2D17D500B8D3D1 /* LoginView.swift in Sources */, 314 | 457128422B2AAB6F00838150 /* String+Extensions.swift in Sources */, 315 | 450871DF2B291C8100F938A2 /* Bundle+Extensions.swift in Sources */, 316 | 457128402B2AAB5C00838150 /* Constants.swift in Sources */, 317 | 457128462B2AAFC700838150 /* AuthModel.swift in Sources */, 318 | 45B9142D2B2D375200B8D3D1 /* TransitionModifier.swift in Sources */, 319 | 450871DE2B291C8100F938A2 /* Double+Extension.swift in Sources */, 320 | 450871C62B291C4B00F938A2 /* NativeTwitchApp.swift in Sources */, 321 | 45AE499D2B3193DB00B6CBEF /* TwitchDeviceAuth.swift in Sources */, 322 | ); 323 | runOnlyForDeploymentPostprocessing = 0; 324 | }; 325 | /* End PBXSourcesBuildPhase section */ 326 | 327 | /* Begin XCBuildConfiguration section */ 328 | 450871CF2B291C4C00F938A2 /* Debug */ = { 329 | isa = XCBuildConfiguration; 330 | buildSettings = { 331 | ALWAYS_SEARCH_USER_PATHS = NO; 332 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 333 | CLANG_ANALYZER_NONNULL = YES; 334 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 335 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 336 | CLANG_ENABLE_MODULES = YES; 337 | CLANG_ENABLE_OBJC_ARC = YES; 338 | CLANG_ENABLE_OBJC_WEAK = YES; 339 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 340 | CLANG_WARN_BOOL_CONVERSION = YES; 341 | CLANG_WARN_COMMA = YES; 342 | CLANG_WARN_CONSTANT_CONVERSION = YES; 343 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 344 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 345 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 346 | CLANG_WARN_EMPTY_BODY = YES; 347 | CLANG_WARN_ENUM_CONVERSION = YES; 348 | CLANG_WARN_INFINITE_RECURSION = YES; 349 | CLANG_WARN_INT_CONVERSION = YES; 350 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 351 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 352 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 353 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 354 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 355 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 356 | CLANG_WARN_STRICT_PROTOTYPES = YES; 357 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 358 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 359 | CLANG_WARN_UNREACHABLE_CODE = YES; 360 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 361 | COPY_PHASE_STRIP = NO; 362 | DEBUG_INFORMATION_FORMAT = dwarf; 363 | ENABLE_STRICT_OBJC_MSGSEND = YES; 364 | ENABLE_TESTABILITY = YES; 365 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 366 | GCC_C_LANGUAGE_STANDARD = gnu17; 367 | GCC_DYNAMIC_NO_PIC = NO; 368 | GCC_NO_COMMON_BLOCKS = YES; 369 | GCC_OPTIMIZATION_LEVEL = 0; 370 | GCC_PREPROCESSOR_DEFINITIONS = ( 371 | "DEBUG=1", 372 | "$(inherited)", 373 | ); 374 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 375 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 376 | GCC_WARN_UNDECLARED_SELECTOR = YES; 377 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 378 | GCC_WARN_UNUSED_FUNCTION = YES; 379 | GCC_WARN_UNUSED_VARIABLE = YES; 380 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 381 | MACOSX_DEPLOYMENT_TARGET = 14.0; 382 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 383 | MTL_FAST_MATH = YES; 384 | ONLY_ACTIVE_ARCH = YES; 385 | SDKROOT = macosx; 386 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 387 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 388 | }; 389 | name = Debug; 390 | }; 391 | 450871D02B291C4C00F938A2 /* Release */ = { 392 | isa = XCBuildConfiguration; 393 | buildSettings = { 394 | ALWAYS_SEARCH_USER_PATHS = NO; 395 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 396 | CLANG_ANALYZER_NONNULL = YES; 397 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 398 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 399 | CLANG_ENABLE_MODULES = YES; 400 | CLANG_ENABLE_OBJC_ARC = YES; 401 | CLANG_ENABLE_OBJC_WEAK = YES; 402 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 403 | CLANG_WARN_BOOL_CONVERSION = YES; 404 | CLANG_WARN_COMMA = YES; 405 | CLANG_WARN_CONSTANT_CONVERSION = YES; 406 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 407 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 408 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 409 | CLANG_WARN_EMPTY_BODY = YES; 410 | CLANG_WARN_ENUM_CONVERSION = YES; 411 | CLANG_WARN_INFINITE_RECURSION = YES; 412 | CLANG_WARN_INT_CONVERSION = YES; 413 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 414 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 415 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 416 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 417 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 418 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 419 | CLANG_WARN_STRICT_PROTOTYPES = YES; 420 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 421 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 422 | CLANG_WARN_UNREACHABLE_CODE = YES; 423 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 424 | COPY_PHASE_STRIP = NO; 425 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 426 | ENABLE_NS_ASSERTIONS = NO; 427 | ENABLE_STRICT_OBJC_MSGSEND = YES; 428 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 429 | GCC_C_LANGUAGE_STANDARD = gnu17; 430 | GCC_NO_COMMON_BLOCKS = YES; 431 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 432 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 433 | GCC_WARN_UNDECLARED_SELECTOR = YES; 434 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 435 | GCC_WARN_UNUSED_FUNCTION = YES; 436 | GCC_WARN_UNUSED_VARIABLE = YES; 437 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 438 | MACOSX_DEPLOYMENT_TARGET = 14.0; 439 | MTL_ENABLE_DEBUG_INFO = NO; 440 | MTL_FAST_MATH = YES; 441 | SDKROOT = macosx; 442 | SWIFT_COMPILATION_MODE = wholemodule; 443 | }; 444 | name = Release; 445 | }; 446 | 450871D22B291C4C00F938A2 /* Debug */ = { 447 | isa = XCBuildConfiguration; 448 | buildSettings = { 449 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 450 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 451 | CODE_SIGN_ENTITLEMENTS = NativeTwitch/NativeTwitch.entitlements; 452 | CODE_SIGN_STYLE = Automatic; 453 | COMBINE_HIDPI_IMAGES = YES; 454 | CURRENT_PROJECT_VERSION = 103; 455 | DEVELOPMENT_ASSET_PATHS = "\"NativeTwitch/Preview Content\""; 456 | DEVELOPMENT_TEAM = 4538W4A79B; 457 | ENABLE_HARDENED_RUNTIME = YES; 458 | ENABLE_PREVIEWS = YES; 459 | GENERATE_INFOPLIST_FILE = YES; 460 | INFOPLIST_FILE = NativeTwitch/Info.plist; 461 | INFOPLIST_KEY_CFBundleDisplayName = NativeTwitch; 462 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; 463 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 464 | LD_RUNPATH_SEARCH_PATHS = ( 465 | "$(inherited)", 466 | "@executable_path/../Frameworks", 467 | ); 468 | MARKETING_VERSION = 4.0.1; 469 | PRODUCT_BUNDLE_IDENTIFIER = com.aayush.opensource.NativeTwitch; 470 | PRODUCT_NAME = "$(TARGET_NAME)"; 471 | SWIFT_EMIT_LOC_STRINGS = YES; 472 | SWIFT_VERSION = 5.0; 473 | }; 474 | name = Debug; 475 | }; 476 | 450871D32B291C4C00F938A2 /* Release */ = { 477 | isa = XCBuildConfiguration; 478 | buildSettings = { 479 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 480 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 481 | CODE_SIGN_ENTITLEMENTS = NativeTwitch/NativeTwitch.entitlements; 482 | CODE_SIGN_STYLE = Automatic; 483 | COMBINE_HIDPI_IMAGES = YES; 484 | CURRENT_PROJECT_VERSION = 103; 485 | DEVELOPMENT_ASSET_PATHS = "\"NativeTwitch/Preview Content\""; 486 | DEVELOPMENT_TEAM = 4538W4A79B; 487 | ENABLE_HARDENED_RUNTIME = YES; 488 | ENABLE_PREVIEWS = YES; 489 | GENERATE_INFOPLIST_FILE = YES; 490 | INFOPLIST_FILE = NativeTwitch/Info.plist; 491 | INFOPLIST_KEY_CFBundleDisplayName = NativeTwitch; 492 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; 493 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 494 | LD_RUNPATH_SEARCH_PATHS = ( 495 | "$(inherited)", 496 | "@executable_path/../Frameworks", 497 | ); 498 | MARKETING_VERSION = 4.0.1; 499 | PRODUCT_BUNDLE_IDENTIFIER = com.aayush.opensource.NativeTwitch; 500 | PRODUCT_NAME = "$(TARGET_NAME)"; 501 | SWIFT_EMIT_LOC_STRINGS = YES; 502 | SWIFT_VERSION = 5.0; 503 | }; 504 | name = Release; 505 | }; 506 | /* End XCBuildConfiguration section */ 507 | 508 | /* Begin XCConfigurationList section */ 509 | 450871BD2B291C4B00F938A2 /* Build configuration list for PBXProject "NativeTwitch" */ = { 510 | isa = XCConfigurationList; 511 | buildConfigurations = ( 512 | 450871CF2B291C4C00F938A2 /* Debug */, 513 | 450871D02B291C4C00F938A2 /* Release */, 514 | ); 515 | defaultConfigurationIsVisible = 0; 516 | defaultConfigurationName = Release; 517 | }; 518 | 450871D12B291C4C00F938A2 /* Build configuration list for PBXNativeTarget "NativeTwitch" */ = { 519 | isa = XCConfigurationList; 520 | buildConfigurations = ( 521 | 450871D22B291C4C00F938A2 /* Debug */, 522 | 450871D32B291C4C00F938A2 /* Release */, 523 | ); 524 | defaultConfigurationIsVisible = 0; 525 | defaultConfigurationName = Release; 526 | }; 527 | /* End XCConfigurationList section */ 528 | }; 529 | rootObject = 450871BA2B291C4B00F938A2 /* Project object */; 530 | } 531 | -------------------------------------------------------------------------------- /NativeTwitch.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /NativeTwitch.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon_16x16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "icon_16x16@2x.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "icon_32x32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "icon_32x32@2x.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "icon_128x128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "icon_128x128@2x.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "icon_256x256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "icon_256x256@2x.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "icon_512x512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "icon_512x512@2x.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_128x128.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_16x16.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_256x256.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_32x32.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_512x512.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIconImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "icon.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "icon@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "icon@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIconImage.imageset/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/AppIconImage.imageset/icon.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIconImage.imageset/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/AppIconImage.imageset/icon@2x.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/AppIconImage.imageset/icon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/AppIconImage.imageset/icon@3x.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/exampleStream.imageset/CleanShot 2023-12-15 at 19.52.34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/exampleStream.imageset/CleanShot 2023-12-15 at 19.52.34.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/exampleStream.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "CleanShot 2023-12-15 at 19.52.34.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/exampleStreamer.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "exampleStreamer@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/exampleStreamer.imageset/exampleStreamer@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/exampleStreamer.imageset/exampleStreamer@2x.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/exampleUser.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "exampleUser@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/exampleUser.imageset/exampleUser@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/exampleUser.imageset/exampleUser@2x.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/menuBarIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "menuIcon.svg", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/menuBarIcon.imageset/menuIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | iconmonstr-twitch-2 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/transparentIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "transparentIcon.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "transparentIcon@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "transparentIcon@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/transparentIcon.imageset/transparentIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/transparentIcon.imageset/transparentIcon.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/transparentIcon.imageset/transparentIcon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/transparentIcon.imageset/transparentIcon@2x.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/transparentIcon.imageset/transparentIcon@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/transparentIcon.imageset/transparentIcon@3x.png -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/twitchColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.963", 9 | "green" : "0.316", 10 | "red" : "0.584" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "srgb", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "1.000", 27 | "green" : "0.346", 28 | "red" : "0.625" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/twitchLogo.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "scale" : "1x" 6 | }, 7 | { 8 | "filename" : "twitchLogo@2x.png", 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /NativeTwitch/Assets.xcassets/twitchLogo.imageset/twitchLogo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aayush9029/NativeTwitch/ece417d33c4ad94e53e02351fdf62bdaf2fcaa28/NativeTwitch/Assets.xcassets/twitchLogo.imageset/twitchLogo@2x.png -------------------------------------------------------------------------------- /NativeTwitch/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-12. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ContentView: View { 11 | @Environment(TwitchVM.self) var twitchVM 12 | 13 | var body: some View { 14 | Group { 15 | if !twitchVM.loggedIn { 16 | LoginView() 17 | } else if twitchVM.loading { 18 | ProgressView() 19 | } else if twitchVM.streams.isEmpty { 20 | NoStreamsView 21 | } else { 22 | StreamsView(twitchVM.streams) 23 | } 24 | } 25 | .task { 26 | await twitchVM.fetchFollowedStreams() 27 | } 28 | .onKeyboardShortcut(key: "r", modifiers: .command) { 29 | Task { 30 | await twitchVM.fetchFollowedStreams() 31 | } 32 | } 33 | } 34 | 35 | var NoStreamsView: some View { 36 | ContentUnavailableView { 37 | Label("Streamers Offline", systemImage: "person.3.fill") 38 | } description: { 39 | Text("Seems like streamers you follow are offline time to follow new ones or touch some grass.") 40 | } actions: { 41 | Button("Refresh", systemImage: "arrow.counterclockwise") { 42 | Task { 43 | await twitchVM.fetchFollowedStreams() 44 | } 45 | } 46 | } 47 | } 48 | } 49 | 50 | #Preview("Streams View") { 51 | ContentView() 52 | .environment(TwitchVM()) 53 | .frame(width: 360, height: 480) 54 | } 55 | -------------------------------------------------------------------------------- /NativeTwitch/Extensions/Bundle+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bundle+Extensions.swift 3 | // Neo 4 | // 5 | // Created by Aayush Pokharel on 2023-11-28. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Bundle { 11 | var appName: String { getInfo("CFBundleName") } 12 | var displayName: String { getInfo("CFBundleDisplayName") } 13 | var bundleID: String { getInfo("CFBundleIdentifier") } 14 | var copyright: String { getInfo("NSHumanReadableCopyright") } 15 | 16 | var appBuild: String { getInfo("CFBundleVersion") } 17 | var appVersion: String { getInfo("CFBundleShortVersionString") } 18 | 19 | func getInfo(_ str: String) -> String { 20 | infoDictionary?[str] as? String ?? "No Information." 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /NativeTwitch/Extensions/Double+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Double+Extension.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2021-10-27. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Double { 11 | var shortStringRepresentation: String { 12 | if self.isZero { 13 | return "0" 14 | } 15 | if self.isNaN { 16 | return "NaN" 17 | } 18 | if self.isInfinite { 19 | return "\(self < 0.0 ? "-" : "+")Infinity" 20 | } 21 | let units = ["", "k", "M"] 22 | var interval = self 23 | var i = 0 24 | while i < units.count - 1 { 25 | if abs(interval) < 1000.0 { 26 | break 27 | } 28 | i += 1 29 | interval /= 1000.0 30 | } 31 | // + 2 to have one digit after the comma, + 1 to not have any. 32 | // Remove the * and the number of digits argument to display all the digits after the comma.\ 33 | 34 | return "\(String(format: "%0.*g", Int(log10(abs(interval))) + 2, interval))\(units[i])" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /NativeTwitch/Extensions/LinearGradient+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LinearGradient+Extensions.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension LinearGradient { 11 | static let bottomMasked: LinearGradient = .init(colors: [.black.opacity(0), .white, .white], startPoint: .top, endPoint: .bottom) 12 | } 13 | -------------------------------------------------------------------------------- /NativeTwitch/Extensions/Logger+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger+Extensions.swift 3 | // Neo 4 | // 5 | // Created by NOX on 2023-11-10. 6 | // 7 | 8 | import Foundation 9 | import os 10 | 11 | extension Logger { 12 | init(category: String) { 13 | self.init(subsystem: Bundle.main.bundleIdentifier!, category: category) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /NativeTwitch/Extensions/String+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Extensions.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-13. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | func toURL() -> URL? { 12 | return URL(string: self) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /NativeTwitch/Extensions/View+Extension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Extension.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2021-10-28. 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Custom View Extensions 11 | extension View { 12 | /// Custom Spacers 13 | @ViewBuilder 14 | func hSpacing(_ alignment: Alignment) -> some View { 15 | self 16 | .frame(maxWidth: .infinity, alignment: alignment) 17 | } 18 | 19 | @ViewBuilder 20 | func vSpacing(_ alignment: Alignment) -> some View { 21 | self 22 | .frame(maxHeight: .infinity, alignment: alignment) 23 | } 24 | 25 | @ViewBuilder 26 | func xSpacing(_ alignment: Alignment) -> some View { 27 | self 28 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) 29 | } 30 | } 31 | 32 | public extension View { 33 | /// Adds an underlying hidden button with a performing action that is triggered on pressed shortcut 34 | /// - Parameters: 35 | /// - key: Key equivalents consist of a letter, punctuation, or function key that can be combined with an optional set of modifier keys to specify a keyboard shortcut. 36 | /// - modifiers: A set of key modifiers that you can add to a gesture. 37 | /// - perform: Action to perform when the shortcut is pressed 38 | func onKeyboardShortcut(key: KeyEquivalent, modifiers: EventModifiers = .command, perform: @escaping () -> ()) -> some View { 39 | ZStack { 40 | Button("") { 41 | perform() 42 | } 43 | .hidden() 44 | .keyboardShortcut(key, modifiers: modifiers) 45 | 46 | self 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /NativeTwitch/Extensions/VisualEffectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VisualEffectView.swift 3 | // Neo 4 | // 5 | // Created by Aayush Pokharel on 2023-11-07. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // MARK: - VisualEffectBlur 11 | 12 | public struct VisualEffectBlur: View { 13 | private let effectSettings: EffectSettings 14 | 15 | public init( 16 | material: NSVisualEffectView.Material = .headerView, 17 | blendingMode: NSVisualEffectView.BlendingMode = .withinWindow, 18 | state: NSVisualEffectView.State = .followsWindowActiveState 19 | ) { 20 | self.effectSettings = EffectSettings(material: material, blendingMode: blendingMode, state: state) 21 | } 22 | 23 | public var body: some View { 24 | Representable(settings: effectSettings).accessibility(hidden: true) 25 | } 26 | 27 | static var hudWindow: VisualEffectBlur { 28 | VisualEffectBlur(material: .hudWindow, blendingMode: .behindWindow, state: .active) 29 | } 30 | } 31 | 32 | // MARK: - EffectSettings 33 | 34 | struct EffectSettings { 35 | let material: NSVisualEffectView.Material 36 | let blendingMode: NSVisualEffectView.BlendingMode 37 | let state: NSVisualEffectView.State 38 | } 39 | 40 | // MARK: - Representable 41 | 42 | extension VisualEffectBlur { 43 | struct Representable: NSViewRepresentable { 44 | let settings: EffectSettings 45 | 46 | func makeNSView(context: Context) -> NSVisualEffectView { 47 | let view = NSVisualEffectView() 48 | updateNSView(view, context: context) 49 | return view 50 | } 51 | 52 | func updateNSView(_ nsView: NSVisualEffectView, context: Context) { 53 | nsView.material = settings.material 54 | nsView.blendingMode = settings.blendingMode 55 | nsView.state = settings.state 56 | } 57 | } 58 | } 59 | 60 | // MARK: - View Extension for Blurred Background 61 | 62 | extension View { 63 | func blurredBackground( 64 | material: NSVisualEffectView.Material = .hudWindow, 65 | blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, 66 | state: NSVisualEffectView.State = .followsWindowActiveState 67 | ) -> some View { 68 | background(VisualEffectBlur(material: material, blendingMode: blendingMode, state: state)) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /NativeTwitch/Helpers/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-11-15. 6 | // 7 | 8 | import AppKit 9 | import SwiftUI 10 | 11 | class AppDelegate: NSObject, NSApplicationDelegate { 12 | private var aboutBoxWindowController: NSWindowController? 13 | 14 | func showAboutPanel() { 15 | if aboutBoxWindowController == nil { 16 | let styleMask: NSWindow.StyleMask = [.closable, .titled, .fullSizeContentView] 17 | let window = NSWindow() 18 | window.styleMask = styleMask 19 | window.isMovableByWindowBackground = true 20 | window.backgroundColor = .clear 21 | window.titlebarAppearsTransparent = true 22 | window.titleVisibility = .hidden 23 | window.contentView = NSHostingView(rootView: AboutView()) 24 | window.center() 25 | aboutBoxWindowController = NSWindowController(window: window) 26 | } 27 | 28 | aboutBoxWindowController?.showWindow(aboutBoxWindowController?.window) 29 | } 30 | 31 | func applicationDidFinishLaunching(_ notification: Notification) { 32 | // NSApp.setActivationPolicy(.prohibited) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /NativeTwitch/Helpers/KeychainHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Keychain helper for iOS/Swift. 3 | // 4 | // https://github.com/evgenyneu/keychain-swift 5 | // 6 | // This file was automatically generated by combining multiple Swift source files. 7 | // 8 | 9 | // ---------------------------- 10 | // 11 | // KeychainSwift.swift 12 | // 13 | // ---------------------------- 14 | 15 | import Foundation 16 | import Security 17 | 18 | // MARK: - Setter Helpers 19 | 20 | extension KeychainSwift { 21 | static let shared = KeychainSwift() 22 | 23 | // AuthModel: Added by the user 24 | static let authKey: String = "com.nativeTwitch.secure.login" 25 | 26 | // UserID: (fetched and stored automatically) 27 | static let userIDKey: String = "com.nativeTwitch.secure.userID" 28 | 29 | static func getAuth() -> AuthModel? { 30 | guard let data = shared.getData(authKey) else { return nil } 31 | let decoder = JSONDecoder() 32 | do { 33 | let auth = try decoder.decode(AuthModel.self, from: data) 34 | return auth 35 | } catch { 36 | print("Error decoding AuthModel: \(error)") 37 | return nil 38 | } 39 | } 40 | 41 | static func login(_ auth: AuthModel) -> Bool { 42 | let encoder = JSONEncoder() 43 | do { 44 | let data = try encoder.encode(auth) 45 | return shared.set(data, forKey: authKey, withAccess: .accessibleWhenUnlocked) 46 | } catch { 47 | print("Error encoding AuthModel: \(error)") 48 | return false 49 | } 50 | } 51 | 52 | static func logout() -> Bool { 53 | return shared.delete(authKey) && shared.delete(userIDKey) 54 | } 55 | 56 | static func getUserID() -> String? { 57 | return shared.get(userIDKey) 58 | } 59 | 60 | static func setUserID(_ userID: String) -> Bool { 61 | return shared.set(userID, forKey: userIDKey) 62 | } 63 | 64 | static func deleteUserID() -> Bool { 65 | return shared.delete(userIDKey) 66 | } 67 | } 68 | 69 | /** 70 | 71 | A collection of helper functions for saving text and data in the keychain. 72 | 73 | */ 74 | open class KeychainSwift { 75 | var lastQueryParameters: [String: Any]? // Used by the unit tests 76 | 77 | /// Contains result code from the last operation. Value is noErr (0) for a successful result. 78 | open var lastResultCode: OSStatus = noErr 79 | 80 | var keyPrefix = "" // Can be useful in test. 81 | 82 | /** 83 | 84 | Specify an access group that will be used to access keychain items. Access groups can be used to share keychain items between applications. When access group value is nil all application access groups are being accessed. Access group name is used by all functions: set, get, delete and clear. 85 | 86 | */ 87 | open var accessGroup: String? 88 | 89 | /** 90 | 91 | Specifies whether the items can be synchronized with other devices through iCloud. Setting this property to true will 92 | add the item to other devices with the `set` method and obtain synchronizable items with the `get` command. Deleting synchronizable items will remove them from all devices. In order for keychain synchronization to work the user must enable "Keychain" in iCloud settings. 93 | 94 | Does not work on macOS. 95 | 96 | */ 97 | open var synchronizable: Bool = false 98 | 99 | private let lock = NSLock() 100 | 101 | /// Instantiate a KeychainSwift object 102 | public init() {} 103 | 104 | /** 105 | 106 | - parameter keyPrefix: a prefix that is added before the key in get/set methods. Note that `clear` method still clears everything from the Keychain. 107 | 108 | */ 109 | public init(keyPrefix: String) { 110 | self.keyPrefix = keyPrefix 111 | } 112 | 113 | /** 114 | 115 | Stores the text value in the keychain item under the given key. 116 | 117 | - parameter key: Key under which the text value is stored in the keychain. 118 | - parameter value: Text string to be written to the keychain. 119 | - parameter withAccess: Value that indicates when your app needs access to the text in the keychain item. By default the .AccessibleWhenUnlocked option is used that permits the data to be accessed only while the device is unlocked by the user. 120 | 121 | - returns: True if the text was successfully written to the keychain. 122 | 123 | */ 124 | @discardableResult 125 | open func set(_ value: String, forKey key: String, 126 | withAccess access: KeychainSwiftAccessOptions? = nil) -> Bool 127 | { 128 | if let value = value.data(using: String.Encoding.utf8) { 129 | return set(value, forKey: key, withAccess: access) 130 | } 131 | 132 | return false 133 | } 134 | 135 | /** 136 | 137 | Stores the data in the keychain item under the given key. 138 | 139 | - parameter key: Key under which the data is stored in the keychain. 140 | - parameter value: Data to be written to the keychain. 141 | - parameter withAccess: Value that indicates when your app needs access to the text in the keychain item. By default the .AccessibleWhenUnlocked option is used that permits the data to be accessed only while the device is unlocked by the user. 142 | 143 | - returns: True if the text was successfully written to the keychain. 144 | 145 | */ 146 | @discardableResult 147 | open func set(_ value: Data, forKey key: String, 148 | withAccess access: KeychainSwiftAccessOptions? = nil) -> Bool 149 | { 150 | // The lock prevents the code to be run simultaneously 151 | // from multiple threads which may result in crashing 152 | lock.lock() 153 | defer { lock.unlock() } 154 | 155 | deleteNoLock(key) // Delete any existing key before saving it 156 | 157 | let accessible = access?.value ?? KeychainSwiftAccessOptions.defaultOption.value 158 | 159 | let prefixedKey = keyWithPrefix(key) 160 | 161 | var query: [String: Any] = [ 162 | KeychainSwiftConstants.klass: kSecClassGenericPassword, 163 | KeychainSwiftConstants.attrAccount: prefixedKey, 164 | KeychainSwiftConstants.valueData: value, 165 | KeychainSwiftConstants.accessible: accessible 166 | ] 167 | 168 | query = addAccessGroupWhenPresent(query) 169 | query = addSynchronizableIfRequired(query, addingItems: true) 170 | lastQueryParameters = query 171 | 172 | lastResultCode = SecItemAdd(query as CFDictionary, nil) 173 | 174 | return lastResultCode == noErr 175 | } 176 | 177 | /** 178 | 179 | Stores the boolean value in the keychain item under the given key. 180 | 181 | - parameter key: Key under which the value is stored in the keychain. 182 | - parameter value: Boolean to be written to the keychain. 183 | - parameter withAccess: Value that indicates when your app needs access to the value in the keychain item. By default the .AccessibleWhenUnlocked option is used that permits the data to be accessed only while the device is unlocked by the user. 184 | 185 | - returns: True if the value was successfully written to the keychain. 186 | 187 | */ 188 | @discardableResult 189 | open func set(_ value: Bool, forKey key: String, 190 | withAccess access: KeychainSwiftAccessOptions? = nil) -> Bool 191 | { 192 | let bytes: [UInt8] = value ? [1] : [0] 193 | let data = Data(bytes) 194 | 195 | return set(data, forKey: key, withAccess: access) 196 | } 197 | 198 | /** 199 | 200 | Retrieves the text value from the keychain that corresponds to the given key. 201 | 202 | - parameter key: The key that is used to read the keychain item. 203 | - returns: The text value from the keychain. Returns nil if unable to read the item. 204 | 205 | */ 206 | open func get(_ key: String) -> String? { 207 | if let data = getData(key) { 208 | if let currentString = String(data: data, encoding: .utf8) { 209 | return currentString 210 | } 211 | 212 | lastResultCode = -67853 // errSecInvalidEncoding 213 | } 214 | 215 | return nil 216 | } 217 | 218 | /** 219 | 220 | Retrieves the data from the keychain that corresponds to the given key. 221 | 222 | - parameter key: The key that is used to read the keychain item. 223 | - parameter asReference: If true, returns the data as reference (needed for things like NEVPNProtocol). 224 | - returns: The text value from the keychain. Returns nil if unable to read the item. 225 | 226 | */ 227 | open func getData(_ key: String, asReference: Bool = false) -> Data? { 228 | // The lock prevents the code to be run simultaneously 229 | // from multiple threads which may result in crashing 230 | lock.lock() 231 | defer { lock.unlock() } 232 | 233 | let prefixedKey = keyWithPrefix(key) 234 | 235 | var query: [String: Any] = [ 236 | KeychainSwiftConstants.klass: kSecClassGenericPassword, 237 | KeychainSwiftConstants.attrAccount: prefixedKey, 238 | KeychainSwiftConstants.matchLimit: kSecMatchLimitOne 239 | ] 240 | 241 | if asReference { 242 | query[KeychainSwiftConstants.returnReference] = kCFBooleanTrue 243 | } else { 244 | query[KeychainSwiftConstants.returnData] = kCFBooleanTrue 245 | } 246 | 247 | query = addAccessGroupWhenPresent(query) 248 | query = addSynchronizableIfRequired(query, addingItems: false) 249 | lastQueryParameters = query 250 | 251 | var result: AnyObject? 252 | 253 | lastResultCode = withUnsafeMutablePointer(to: &result) { 254 | SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) 255 | } 256 | 257 | if lastResultCode == noErr { 258 | return result as? Data 259 | } 260 | 261 | return nil 262 | } 263 | 264 | /** 265 | 266 | Retrieves the boolean value from the keychain that corresponds to the given key. 267 | 268 | - parameter key: The key that is used to read the keychain item. 269 | - returns: The boolean value from the keychain. Returns nil if unable to read the item. 270 | 271 | */ 272 | open func getBool(_ key: String) -> Bool? { 273 | guard let data = getData(key) else { return nil } 274 | guard let firstBit = data.first else { return nil } 275 | return firstBit == 1 276 | } 277 | 278 | /** 279 | 280 | Deletes the single keychain item specified by the key. 281 | 282 | - parameter key: The key that is used to delete the keychain item. 283 | - returns: True if the item was successfully deleted. 284 | 285 | */ 286 | @discardableResult 287 | open func delete(_ key: String) -> Bool { 288 | // The lock prevents the code to be run simultaneously 289 | // from multiple threads which may result in crashing 290 | lock.lock() 291 | defer { lock.unlock() } 292 | 293 | return deleteNoLock(key) 294 | } 295 | 296 | /** 297 | Return all keys from keychain 298 | 299 | - returns: An string array with all keys from the keychain. 300 | 301 | */ 302 | public var allKeys: [String] { 303 | var query: [String: Any] = [ 304 | KeychainSwiftConstants.klass: kSecClassGenericPassword, 305 | KeychainSwiftConstants.returnData: true, 306 | KeychainSwiftConstants.returnAttributes: true, 307 | KeychainSwiftConstants.returnReference: true, 308 | KeychainSwiftConstants.matchLimit: KeychainSwiftConstants.secMatchLimitAll 309 | ] 310 | 311 | query = addAccessGroupWhenPresent(query) 312 | query = addSynchronizableIfRequired(query, addingItems: false) 313 | 314 | var result: AnyObject? 315 | 316 | let lastResultCode = withUnsafeMutablePointer(to: &result) { 317 | SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0)) 318 | } 319 | 320 | if lastResultCode == noErr { 321 | return (result as? [[String: Any]])?.compactMap { 322 | $0[KeychainSwiftConstants.attrAccount] as? String 323 | } ?? [] 324 | } 325 | 326 | return [] 327 | } 328 | 329 | /** 330 | 331 | Same as `delete` but is only accessed internally, since it is not thread safe. 332 | 333 | - parameter key: The key that is used to delete the keychain item. 334 | - returns: True if the item was successfully deleted. 335 | 336 | */ 337 | @discardableResult 338 | func deleteNoLock(_ key: String) -> Bool { 339 | let prefixedKey = keyWithPrefix(key) 340 | 341 | var query: [String: Any] = [ 342 | KeychainSwiftConstants.klass: kSecClassGenericPassword, 343 | KeychainSwiftConstants.attrAccount: prefixedKey 344 | ] 345 | 346 | query = addAccessGroupWhenPresent(query) 347 | query = addSynchronizableIfRequired(query, addingItems: false) 348 | lastQueryParameters = query 349 | 350 | lastResultCode = SecItemDelete(query as CFDictionary) 351 | 352 | return lastResultCode == noErr 353 | } 354 | 355 | /** 356 | 357 | Deletes all Keychain items used by the app. Note that this method deletes all items regardless of the prefix settings used for initializing the class. 358 | 359 | - returns: True if the keychain items were successfully deleted. 360 | 361 | */ 362 | @discardableResult 363 | open func clear() -> Bool { 364 | // The lock prevents the code to be run simultaneously 365 | // from multiple threads which may result in crashing 366 | lock.lock() 367 | defer { lock.unlock() } 368 | 369 | var query: [String: Any] = [kSecClass as String: kSecClassGenericPassword] 370 | query = addAccessGroupWhenPresent(query) 371 | query = addSynchronizableIfRequired(query, addingItems: false) 372 | lastQueryParameters = query 373 | 374 | lastResultCode = SecItemDelete(query as CFDictionary) 375 | 376 | return lastResultCode == noErr 377 | } 378 | 379 | /// Returns the key with currently set prefix. 380 | func keyWithPrefix(_ key: String) -> String { 381 | return "\(keyPrefix)\(key)" 382 | } 383 | 384 | func addAccessGroupWhenPresent(_ items: [String: Any]) -> [String: Any] { 385 | guard let accessGroup = accessGroup else { return items } 386 | 387 | var result: [String: Any] = items 388 | result[KeychainSwiftConstants.accessGroup] = accessGroup 389 | return result 390 | } 391 | 392 | /** 393 | 394 | Adds kSecAttrSynchronizable: kSecAttrSynchronizableAny` item to the dictionary when the `synchronizable` property is true. 395 | 396 | - parameter items: The dictionary where the kSecAttrSynchronizable items will be added when requested. 397 | - parameter addingItems: Use `true` when the dictionary will be used with `SecItemAdd` method (adding a keychain item). For getting and deleting items, use `false`. 398 | 399 | - returns: the dictionary with kSecAttrSynchronizable item added if it was requested. Otherwise, it returns the original dictionary. 400 | 401 | */ 402 | func addSynchronizableIfRequired(_ items: [String: Any], addingItems: Bool) -> [String: Any] { 403 | if !synchronizable { return items } 404 | var result: [String: Any] = items 405 | result[KeychainSwiftConstants.attrSynchronizable] = addingItems == true ? true : kSecAttrSynchronizableAny 406 | return result 407 | } 408 | } 409 | 410 | // ---------------------------- 411 | // 412 | // TegKeychainConstants.swift 413 | // 414 | // ---------------------------- 415 | 416 | import Foundation 417 | import Security 418 | 419 | /// Constants used by the library 420 | public enum KeychainSwiftConstants { 421 | /// Specifies a Keychain access group. Used for sharing Keychain items between apps. 422 | public static var accessGroup: String { return toString(kSecAttrAccessGroup) } 423 | 424 | /** 425 | 426 | A value that indicates when your app needs access to the data in a keychain item. The default value is AccessibleWhenUnlocked. For a list of possible values, see KeychainSwiftAccessOptions. 427 | 428 | */ 429 | public static var accessible: String { return toString(kSecAttrAccessible) } 430 | 431 | /// Used for specifying a String key when setting/getting a Keychain value. 432 | public static var attrAccount: String { return toString(kSecAttrAccount) } 433 | 434 | /// Used for specifying synchronization of keychain items between devices. 435 | public static var attrSynchronizable: String { return toString(kSecAttrSynchronizable) } 436 | 437 | /// An item class key used to construct a Keychain search dictionary. 438 | public static var klass: String { return toString(kSecClass) } 439 | 440 | /// Specifies the number of values returned from the keychain. The library only supports single values. 441 | public static var matchLimit: String { return toString(kSecMatchLimit) } 442 | 443 | /// A return data type used to get the data from the Keychain. 444 | public static var returnData: String { return toString(kSecReturnData) } 445 | 446 | /// Used for specifying a value when setting a Keychain value. 447 | public static var valueData: String { return toString(kSecValueData) } 448 | 449 | /// Used for returning a reference to the data from the keychain 450 | public static var returnReference: String { return toString(kSecReturnPersistentRef) } 451 | 452 | /// A key whose value is a Boolean indicating whether or not to return item attributes 453 | public static var returnAttributes: String { return toString(kSecReturnAttributes) } 454 | 455 | /// A value that corresponds to matching an unlimited number of items 456 | public static var secMatchLimitAll: String { return toString(kSecMatchLimitAll) } 457 | 458 | static func toString(_ value: CFString) -> String { 459 | return value as String 460 | } 461 | } 462 | 463 | // ---------------------------- 464 | // 465 | // KeychainSwiftAccessOptions.swift 466 | // 467 | // ---------------------------- 468 | 469 | import Security 470 | 471 | /** 472 | 473 | These options are used to determine when a keychain item should be readable. The default value is AccessibleWhenUnlocked. 474 | 475 | */ 476 | public enum KeychainSwiftAccessOptions { 477 | /** 478 | 479 | The data in the keychain item can be accessed only while the device is unlocked by the user. 480 | 481 | This is recommended for items that need to be accessible only while the application is in the foreground. Items with this attribute migrate to a new device when using encrypted backups. 482 | 483 | This is the default value for keychain items added without explicitly setting an accessibility constant. 484 | 485 | */ 486 | case accessibleWhenUnlocked 487 | 488 | /** 489 | 490 | The data in the keychain item can be accessed only while the device is unlocked by the user. 491 | 492 | This is recommended for items that need to be accessible only while the application is in the foreground. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present. 493 | 494 | */ 495 | case accessibleWhenUnlockedThisDeviceOnly 496 | 497 | /** 498 | 499 | The data in the keychain item cannot be accessed after a restart until the device has been unlocked once by the user. 500 | 501 | After the first unlock, the data remains accessible until the next restart. This is recommended for items that need to be accessed by background applications. Items with this attribute migrate to a new device when using encrypted backups. 502 | 503 | */ 504 | case accessibleAfterFirstUnlock 505 | 506 | /** 507 | 508 | The data in the keychain item cannot be accessed after a restart until the device has been unlocked once by the user. 509 | 510 | After the first unlock, the data remains accessible until the next restart. This is recommended for items that need to be accessed by background applications. Items with this attribute do not migrate to a new device. Thus, after restoring from a backup of a different device, these items will not be present. 511 | 512 | */ 513 | case accessibleAfterFirstUnlockThisDeviceOnly 514 | 515 | /** 516 | 517 | The data in the keychain can only be accessed when the device is unlocked. Only available if a passcode is set on the device. 518 | 519 | This is recommended for items that only need to be accessible while the application is in the foreground. Items with this attribute never migrate to a new device. After a backup is restored to a new device, these items are missing. No items can be stored in this class on devices without a passcode. Disabling the device passcode causes all items in this class to be deleted. 520 | 521 | */ 522 | case accessibleWhenPasscodeSetThisDeviceOnly 523 | 524 | static var defaultOption: KeychainSwiftAccessOptions { 525 | return .accessibleWhenUnlocked 526 | } 527 | 528 | var value: String { 529 | switch self { 530 | case .accessibleWhenUnlocked: 531 | return toString(kSecAttrAccessibleWhenUnlocked) 532 | 533 | case .accessibleWhenUnlockedThisDeviceOnly: 534 | return toString(kSecAttrAccessibleWhenUnlockedThisDeviceOnly) 535 | 536 | case .accessibleAfterFirstUnlock: 537 | return toString(kSecAttrAccessibleAfterFirstUnlock) 538 | 539 | case .accessibleAfterFirstUnlockThisDeviceOnly: 540 | return toString(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) 541 | 542 | case .accessibleWhenPasscodeSetThisDeviceOnly: 543 | return toString(kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly) 544 | } 545 | } 546 | 547 | func toString(_ value: CFString) -> String { 548 | return KeychainSwiftConstants.toString(value) 549 | } 550 | } 551 | -------------------------------------------------------------------------------- /NativeTwitch/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleURLTypes 6 | 7 | 8 | CFBundleTypeRole 9 | Editor 10 | CFBundleURLName 11 | nativetwitch 12 | CFBundleURLSchemes 13 | 14 | nativetwitch 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /NativeTwitch/Models/AuthModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AuthModel.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-13. 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: - Model Stored in Keychain 11 | 12 | struct AuthModel: Codable { 13 | var clientID: String 14 | var accessToken: String 15 | 16 | init(_ clientID: String, _ accessToken: String) { 17 | self.clientID = clientID 18 | self.accessToken = accessToken 19 | } 20 | 21 | static var empty: AuthModel = .init("", "") 22 | } 23 | -------------------------------------------------------------------------------- /NativeTwitch/Models/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-13. 6 | // 7 | 8 | import Foundation 9 | 10 | enum Constants { 11 | static let donateLink = "https://www.buymeacoffee.com/swiftdev".toURL()! 12 | static let baseAPI = "https://api.twitch.tv/helix" 13 | static let followedAPI = "\(baseAPI)/streams/followed" 14 | static let streamerInfoURL = "\(baseAPI)/users".toURL()! 15 | static let oauthValidateURL = "https://id.twitch.tv/oauth2/validate".toURL()! 16 | 17 | static func followedAPIURL(with userID: String) -> URL? { 18 | return "\(followedAPI)?user_id=\(userID)".toURL() 19 | } 20 | 21 | // Oauth flow 22 | static let clientID = "gp762nuuoqcoxypju8c569th9wz7q5" 23 | static let scopes = ["user:read:follows", "user:read:email", "user:edit:follows"].joined(separator: "+") 24 | } 25 | -------------------------------------------------------------------------------- /NativeTwitch/Models/OauthValidate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OauthValidate.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-13. 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: - OauthValidate 11 | 12 | struct OauthValidate: Codable { 13 | let clientID, login: String 14 | let scopes: [String] 15 | let userID: String 16 | let expiresIn: Int 17 | 18 | enum CodingKeys: String, CodingKey { 19 | case clientID = "client_id" 20 | case login, scopes 21 | case userID = "user_id" 22 | case expiresIn = "expires_in" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /NativeTwitch/Models/Streams.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Streams.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-13. 6 | // 7 | 8 | import Foundation 9 | 10 | // MARK: - Streams 11 | 12 | struct TwitchResponse: Codable { 13 | let data: [StreamModel] 14 | } 15 | 16 | // MARK: - StreamModel 17 | 18 | struct StreamModel: Codable, Identifiable { 19 | let id, userID, userLogin, userName: String 20 | let gameID, gameName, title: String 21 | let viewerCount: Int 22 | let startedAt: String 23 | let language, thumbnailURL: String 24 | let tags: [String] 25 | let isMature: Bool 26 | 27 | enum CodingKeys: String, CodingKey { 28 | case id 29 | case userID = "user_id" 30 | case userLogin = "user_login" 31 | case userName = "user_name" 32 | case gameID = "game_id" 33 | case gameName = "game_name" 34 | case title 35 | case viewerCount = "viewer_count" 36 | case startedAt = "started_at" 37 | case language 38 | case thumbnailURL = "thumbnail_url" 39 | case tags 40 | case isMature = "is_mature" 41 | } 42 | 43 | // Computed Properties 44 | var viewers: String { 45 | return Double(viewerCount).shortStringRepresentation 46 | } 47 | 48 | var startedDate: Date { 49 | let formatter = ISO8601DateFormatter() 50 | formatter.formatOptions = [.withInternetDateTime] 51 | 52 | if let date = formatter.date(from: startedAt) { 53 | return date 54 | } else { 55 | return Date.now 56 | } 57 | } 58 | 59 | var streamURL: URL { 60 | return "https://www.twitch.tv/\(userLogin)".toURL()! 61 | } 62 | 63 | var chatURL: URL { 64 | return "https://www.twitch.tv/popout/\(userLogin)/chat".toURL()! 65 | } 66 | 67 | var thumbnail: URL? { 68 | return URL( 69 | string: thumbnailURL 70 | .replacingOccurrences(of: "{width}", with: "720") 71 | .replacingOccurrences(of: "{height}", with: "360") 72 | ) 73 | } 74 | } 75 | 76 | extension TwitchResponse { 77 | // Mock Data 78 | static let example: TwitchResponse = .init(data: [.xQc, .pokelawls]) 79 | } 80 | 81 | extension StreamModel { 82 | // Mock Data 83 | static let xQc: StreamModel = .init( 84 | id: "49932260029", 85 | userID: "71092938", 86 | userLogin: "xqc", 87 | userName: "xQc", 88 | gameID: "32982", 89 | gameName: "Grand Theft Auto V", 90 | title: "🤖CLICK🤖NO-PIXEL 4.0🤖LAUNCH🤖MERCH🤖IS LIVE🤖BIG JUICER🤖RP🤖IS BACK🤖WOOHOO🤖DRAMA🤖IS BACK🤖YAY🤖", 91 | viewerCount: 71775, 92 | startedAt: "2023-12-15T18:33:00Z", 93 | language: "en", 94 | thumbnailURL: "https://static-cdn.jtvnw.net/previews-ttv/live_user_xqc-{width}x{height}.jpg", 95 | tags: ["English", "vtuber", "depression", "adhd", "psychosis", "xqc", "femboy", "anime", "reaction", "IRL"], 96 | isMature: false 97 | ) 98 | static let pokelawls: StreamModel = .init( 99 | id: "43224626107", 100 | userID: "12943173", 101 | userLogin: "pokelawls", 102 | userName: "pokelawls", 103 | gameID: "33214", 104 | gameName: "Fortnite", 105 | title: "Cool", 106 | viewerCount: 3355, 107 | startedAt: "2023-12-13T23:14:42Z", 108 | language: "en", 109 | thumbnailURL: "https://static-cdn.jtvnw.net/previews-ttv/live_user_pokelawls-{width}x{height}.jpg", 110 | tags: ["frog", 111 | "gigi", 112 | "Depression", 113 | "Water", 114 | "English"], 115 | isMature: true 116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /NativeTwitch/NativeTwitch.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /NativeTwitch/NativeTwitchApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NativeTwitchApp.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-12. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct NativeTwitchApp: App { 12 | @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate 13 | var twitchVM: TwitchVM = .init() 14 | 15 | var body: some Scene { 16 | MenuBarExtra { 17 | ContentView() 18 | .environment(twitchVM) 19 | .frame(width: 320, height: 536) 20 | 21 | } label: { 22 | Image(.menuBarIcon) 23 | } 24 | .menuBarExtraStyle(.window) 25 | .commands { 26 | CommandGroup(replacing: CommandGroupPlacement.appInfo) { 27 | Button("About NativeTwitch") { appDelegate.showAboutPanel() } 28 | .keyboardShortcut(KeyEquivalent("i"), modifiers: .command) 29 | } 30 | CommandGroup(replacing: .systemServices) { 31 | Button("Hide Application, Maintain Menu Bar") { 32 | twitchVM.showOnlyMenu.toggle() 33 | NSApp.setActivationPolicy(.prohibited) 34 | } 35 | .keyboardShortcut(KeyEquivalent("q"), modifiers: .option) 36 | } 37 | CommandGroup(replacing: .appVisibility) { 38 | if twitchVM.loggedIn { 39 | Button("Log Out") { 40 | twitchVM.logout() 41 | } 42 | .keyboardShortcut(KeyEquivalent("q"), modifiers: .shift) 43 | } 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /NativeTwitch/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /NativeTwitch/Shaders/AnimatedGradientFill.metal: -------------------------------------------------------------------------------- 1 | // 2 | // AnimatedGradientFill.metal 3 | // Inferno 4 | // https://www.github.com/twostraws/Inferno 5 | // See LICENSE for license information. 6 | // 7 | 8 | #include 9 | using namespace metal; 10 | 11 | /// A shader that generates a constantly cycling color gradient, centered 12 | /// on the input view. 13 | /// 14 | /// This works be calculating the angle from center of our input view 15 | /// to the current pixel, then using that angle to create RGB values. 16 | /// Using abs() for those color components ensures all values lie in 17 | /// the range 0 to 1. 18 | /// 19 | /// - Parameter position: The user-space coordinate of the current pixel. 20 | /// - Parameter color: The current color of the pixel. 21 | /// - Parameter time: The number of elapsed seconds since the shader was created 22 | /// - Returns: The new pixel color. 23 | [[ stitchable ]] half4 animatedGradientFill(float2 position, half4 color, float2 size, float time) { 24 | // Calculate our coordinate in UV space, 0 to 1. 25 | float2 uv = position / size; 26 | 27 | // Get the same UV in the range -1 to 1, so that 28 | // 0 is in the center. 29 | float2 rp = uv * 2 - 1; 30 | 31 | // Calculate the angle top this pixel, adding in time 32 | // so it's constantly changing. 33 | float angle = atan2(rp.y, rp.x) + time; 34 | 35 | // Send back variations on the sine of that angle, so we 36 | // get a range of colors. The use of abs() here avoids 37 | // negative values for any color component. 38 | return half4(abs(sin(angle)), abs(sin(angle + 2)), abs(sin(angle + 4)), color.a) * color.a; 39 | } 40 | -------------------------------------------------------------------------------- /NativeTwitch/Shaders/GenerativePreview.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GenerativeShader.swift 3 | // Inferno 4 | // https://www.github.com/twostraws/Inferno 5 | // See LICENSE for license information. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // 11 | // GenerativePreview.swift 12 | // Inferno 13 | // https://www.github.com/twostraws/Inferno 14 | // See LICENSE for license information. 15 | // 16 | 17 | import SwiftUI 18 | 19 | /// A trivial SwiftUI view that renders a Metal shader into a whole 20 | /// rectangle space, so it has complete control over rendering. 21 | struct GenerativePreview: View { 22 | /// The initial time this view was created, so we can send 23 | /// elapsed time to the shader. 24 | @State private var start = Date.now 25 | 26 | /// The shader we're rendering. 27 | var shader: GenerativeShader 28 | 29 | var body: some View { 30 | VStack { 31 | TimelineView(.animation) { tl in 32 | let time = start.distance(to: tl.date) 33 | 34 | Rectangle() 35 | .visualEffect { content, proxy in 36 | content.colorEffect( 37 | shader.createShader( 38 | elapsedTime: time, 39 | size: proxy.size 40 | ) 41 | ) 42 | } 43 | } 44 | } 45 | } 46 | } 47 | 48 | #Preview { 49 | GenerativePreview(shader: .sineBow) 50 | } 51 | 52 | /// A shader that generates its contents fully from scratch. 53 | struct GenerativeShader: Hashable, Identifiable { 54 | /// The unique, random identifier for this shader, so we can show these 55 | /// things in a loop. 56 | var id = UUID() 57 | 58 | /// The human-readable name for this shader. This must work with the 59 | /// String-ShaderName extension so the name matches the underlying 60 | /// Metal shader function name. 61 | var name: String 62 | 63 | /// Some shaders need completely custom initialization, so this is effectively 64 | /// a trap door to allow that to happen rather than squeeze all sorts of 65 | /// special casing into the code. 66 | var initializer: ((_ time: Double, _ size: CGSize) -> Shader)? 67 | 68 | /// We need a custom equatable conformance to compare only the IDs, because 69 | /// the `initializer` property blocks the synthesized conformance. 70 | static func ==(lhs: GenerativeShader, rhs: GenerativeShader) -> Bool { 71 | lhs.id == rhs.id 72 | } 73 | 74 | /// We need a custom hashable conformance to compare only the IDs, because 75 | /// the `initializer` property blocks the synthesized conformance. 76 | func hash(into hasher: inout Hasher) { 77 | hasher.combine(id) 78 | } 79 | 80 | /// Converts this shader to its Metal shader by resolving its name. 81 | func createShader(elapsedTime: Double, size: CGSize) -> Shader { 82 | if let initializer { 83 | return initializer(elapsedTime, size) 84 | } else { 85 | let shader = ShaderLibrary[dynamicMember: name.shaderName] 86 | return shader( 87 | .float2(size), 88 | .float(elapsedTime) 89 | ) 90 | } 91 | } 92 | 93 | /// An example shader used for Xcode previews. 94 | static let example = sineBow 95 | 96 | static let animatedGradient = GenerativeShader(name: "AnimatedGradientFill") 97 | 98 | static let sineBow = GenerativeShader(name: "Sinebow") 99 | 100 | static let lightGrid = GenerativeShader(name: "Light Grid") { time, size in 101 | let shader = ShaderLibrary[dynamicMember: "lightGrid"] 102 | return shader( 103 | .float2(size), 104 | .float(time), 105 | .float(8), 106 | .float(3), 107 | .float(1), 108 | .float(3) 109 | ) 110 | } 111 | } 112 | 113 | extension String { 114 | var shaderName: String { 115 | let camelCase = prefix(1).lowercased() + dropFirst() 116 | return camelCase.replacing(" ", with: "") 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /NativeTwitch/Shaders/LightGrid.metal: -------------------------------------------------------------------------------- 1 | // 2 | // LightGrid.metal 3 | // Inferno 4 | // https://www.github.com/twostraws/Inferno 5 | // See LICENSE for license information. 6 | // 7 | 8 | #include 9 | using namespace metal; 10 | 11 | /// π to a large degree of accuracy. 12 | #define M_PI 3.14159265358979323846264338327950288 13 | 14 | /// Creates a grid of multi-colored flashing lights. 15 | /// 16 | /// This works by creating a grid of colors by chunking the texture according 17 | /// to the density from the user. Each chunk is then assigned a random color 18 | /// variance using the same sine trick documented in WhiteNoise, which makes 19 | /// it fluctuate differently from other chunks around it. 20 | /// 21 | /// We then calculate the color for each chunk by taking a base color and 22 | /// adjusting it based on the random color variance we just calculated, so 23 | /// that each chunk displays a different color. This is done using sin() so we 24 | //get a smooth color modulation. 25 | /// 26 | /// Finally, we pulsate each chunk so that it glows up and down, with black space 27 | /// between each chunk to create delineated a light effect. The black space is 28 | /// created using another call to sin() so that the color ramps from 0 to 1 then 29 | /// back down again. 30 | /// 31 | /// - Parameter position: The user-space coordinate of the current pixel. 32 | /// - Parameter color: The current color of the pixel. 33 | /// - Parameter size: The size of the whole image, in user-space. 34 | /// - Parameter time: The number of elapsed seconds since the shader was created 35 | /// - Parameter density: How many rows and columns to create. A range of 1 to 50 36 | /// works well; try starting with 8. 37 | /// - Parameter speed: How fast to make the lights vary their color. Higher values 38 | /// cause lights to flash faster and vary in color more. A range of 1 to 20 works well; 39 | /// try starting with 3. 40 | /// - Parameter groupSize: How many lights to place in each group. A range of 1 to 8 41 | /// works well depending on your density; starting with 1. 42 | /// - Parameter brightness: How bright to make the lights. A range of 0.2 to 10 works 43 | /// well; try starting with 3. 44 | /// - Returns: The new pixel color. 45 | [[ stitchable ]] half4 lightGrid(float2 position, half4 color, float2 size, float time, float density, float speed, float groupSize, float brightness) { 46 | // Calculate our aspect ratio. 47 | float aspectRatio = size.x / size.y; 48 | 49 | // Calculate our coordinate in UV space, 0 to 1. 50 | float2 uv = position / size; 51 | 52 | // Make sure we can create the effect roughly equally no 53 | // matter what aspect ratio we're in. 54 | uv.x *= aspectRatio; 55 | 56 | // If it's not transparent… 57 | if (color.a > 0) { 58 | // STEP 1: Split the grid up into groups based on user input. 59 | float2 point = uv * density; 60 | 61 | // STEP 2: Calculate the color variance for each group 62 | // pick two numbers that are unlikely to repeat. 63 | float2 nonRepeating = float2(12.9898, 78.233); 64 | 65 | // Assign this pixel to a group number. 66 | float2 groupNumber = floor(point); 67 | 68 | // Multiply our group number by the non-repeating 69 | // numbers, then add them together. 70 | float sum = dot(groupNumber, nonRepeating); 71 | 72 | // Calculate the sine of our sum to get a range 73 | // between -1 and 1. 74 | float sine = sin(sum); 75 | 76 | // Multiply the sine by a big, non-repeating number 77 | // so that even a small change will result in 78 | // a big color jump. 79 | float hugeNumber = sine * 43758.5453; 80 | 81 | // Calculate the sine of our time and our huge number 82 | // and map it to the range 0...1. 83 | float variance = (0.5 * sin(time + hugeNumber)) + 0.5; 84 | 85 | // Adjust the color variance by the provided speed. 86 | float acceleratedVariance = speed * variance; 87 | 88 | 89 | // STEP 3: Calculate the final color for this group. 90 | // Select a base color to work from. 91 | half3 baseColor = half3(3, 1.5, 0); 92 | 93 | // Apply our variation to the base color, factoring in time. 94 | half3 variedColor = baseColor + acceleratedVariance + time; 95 | 96 | // Calculate the sine of our varied color so it has 97 | // the range -1 to 1. 98 | half3 variedColorSine = sin(variedColor); 99 | 100 | // Adjust the sine to lie in the range 0...1. 101 | half3 newColor = (0.5h * variedColorSine) + 0.5h; 102 | 103 | 104 | // STEP 4: Now we know the color, calculate the color pulse 105 | // Start by moving down and left a little to create black 106 | // lines at intersection points. 107 | float2 adjustedGroupSize = M_PI * 2 * groupSize * (point - (0.25 / groupSize)); 108 | 109 | // Calculate the sine of our group size, then adjust it 110 | // to lie in the range 0...1. 111 | float2 groupSine = (0.5 * sin(adjustedGroupSize)) + 0.5; 112 | 113 | // Use the sine to calculate a pulsating value between 114 | // 0 and 1, making our group fluctuate together. 115 | float2 pulse = smoothstep(0, 1, groupSine); 116 | 117 | // Calculate the final color by combining the pulse 118 | // strength and user brightness with the color 119 | // for this square. 120 | return half4(newColor * pulse.x * pulse.y * brightness, 1) * color.a; 121 | } else { 122 | // Use the current (transparent) color. 123 | return color; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /NativeTwitch/Shaders/Sinebow.metal: -------------------------------------------------------------------------------- 1 | // 2 | // Sinebow.metal 3 | // Inferno 4 | // https://www.github.com/twostraws/Inferno 5 | // See LICENSE for license information. 6 | // 7 | 8 | #include 9 | using namespace metal; 10 | 11 | /// A shader that generates multiple twisting and turning lines that cycle through colors. 12 | /// 13 | /// This shader calculates how far each pixel is from one of 10 lines. 14 | /// Each line has its own undulating color and position based on various 15 | /// sine waves, so the pixel's color is calculating by starting from black 16 | /// and adding in a little of each line's color based on its distance. 17 | /// 18 | /// - Parameter position: The user-space coordinate of the current pixel. 19 | /// - Parameter color: The current color of the pixel. 20 | /// - Parameter size: The size of the whole image, in user-space. 21 | /// - Parameter time: The number of elapsed seconds since the shader was created 22 | /// - Returns: The new pixel color. 23 | [[ stitchable ]] half4 sinebow(float2 position, half4 color, float2 size, float time) { 24 | // Calculate our aspect ratio. 25 | float aspectRatio = size.x / size.y; 26 | 27 | // Calculate our coordinate in UV space, -1 to 1. 28 | float2 uv = (position / size.x) * 2 - 1; 29 | 30 | // Make sure we can create the effect roughly equally no 31 | // matter what aspect ratio we're in. 32 | uv.x /= aspectRatio; 33 | 34 | // Calculate the overall wave movement. 35 | float wave = sin(uv.x + time); 36 | 37 | // Square that movement, and multiply by a large number 38 | // to make the peaks and troughs be nice and big. 39 | wave *= wave * 50; 40 | 41 | // Assume a black color by default. 42 | half3 waveColor = half3(0); 43 | 44 | // Create 10 lines in total. 45 | for (float i = 0; i < 10; i++) { 46 | // The base brightness of this pixel is 1%, but we 47 | // need to factor in the position after our wave 48 | // calculation is taken into account. The abs() 49 | // call ensures negative numbers become positive, 50 | // so we care about the absolute distance to the 51 | // nearest line, rather than ignoring values that 52 | // are negative. 53 | float luma = abs(1 / (100 * uv.y + wave)); 54 | 55 | // This calculates a second sine wave that's unique 56 | // to each line, so we get waves inside waves. 57 | float y = sin(uv.x * sin(time) + i * 0.2 + time); 58 | 59 | // This offsets each line by that second wave amount, 60 | // so the waves move non-uniformly. 61 | uv.y += 0.05 * y; 62 | 63 | // Our final color is based on fixed red and blue 64 | // values, but green fluctuates much more so that 65 | // the overall brightness varies more randomly. 66 | // The * 0.5 + 0.5 part ensures the sin() values 67 | // are between 0 and 1 rather than -1 and 1. 68 | half3 rainbow = half3( 69 | sin(i * 0.3 + time) * 0.5 + 0.5, 70 | sin(i * 0.3 + 2 + sin(time * 0.3) * 2) * 0.5 + 0.5, 71 | sin(i * 0.3 + 4) * 0.5 + 0.5 72 | ); 73 | 74 | // Add that to the current wave color, ensuring that 75 | // pixels receive some brightness from all lines. 76 | waveColor += rainbow * luma; 77 | } 78 | 79 | // Send back the finished color, taking into account the 80 | // current alpha value. 81 | return half4(waveColor, color.a) * color.a; 82 | } 83 | -------------------------------------------------------------------------------- /NativeTwitch/ViewModels/TwitchDeviceAuth.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TwitchDeviceAuth.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-19. 6 | // 7 | 8 | import Foundation 9 | import os 10 | 11 | class TwitchDeviceAuth { 12 | let logger = Logger(category: "🔑") 13 | 14 | let clientID: String = Constants.clientID 15 | let scope: String = Constants.scopes 16 | 17 | func startDeviceAuthorization() async throws -> (deviceCode: String, userCode: String, verificationUri: String) { 18 | logger.log("Starting Device Authorization") 19 | let url = URL(string: "https://id.twitch.tv/oauth2/device")! 20 | var request = URLRequest(url: url) 21 | request.httpMethod = "POST" 22 | request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 23 | let bodyParameters = "client_id=\(clientID)&scopes=\(scope)" 24 | request.httpBody = bodyParameters.data(using: .utf8) 25 | 26 | let (data, _) = try await URLSession.shared.data(for: request) 27 | let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] 28 | print("Start Device Authorization \(json)") 29 | guard let deviceCode = json["device_code"] as? String, 30 | let userCode = json["user_code"] as? String, 31 | let verificationUri = json["verification_uri"] as? String 32 | else { 33 | throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response data"]) 34 | } 35 | 36 | return (deviceCode, userCode, verificationUri) 37 | } 38 | 39 | func pollForToken(deviceCode: String) async throws -> String { 40 | logger.log("Polling for Token") 41 | let url = URL(string: "https://id.twitch.tv/oauth2/token")! 42 | var request = URLRequest(url: url) 43 | request.httpMethod = "POST" 44 | request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 45 | let bodyParameters = "client_id=\(clientID)&device_code=\(deviceCode)&grant_type=urn:ietf:params:oauth:grant-type:device_code" 46 | request.httpBody = bodyParameters.data(using: .utf8) 47 | 48 | let (data, _) = try await URLSession.shared.data(for: request) 49 | let json = try JSONSerialization.jsonObject(with: data) as! [String: Any] 50 | print("pollForToken \(json)") 51 | if let accessToken = json["access_token"] as? String { 52 | // Store the access token securely 53 | return accessToken 54 | } else { 55 | throw NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Authorization pending or other error"]) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /NativeTwitch/ViewModels/TwitchVM.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TwitchVM.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-13. 6 | // 7 | 8 | import os 9 | import SwiftUI 10 | 11 | @Observable 12 | class TwitchVM { 13 | private let logger: Logger = .init(category: "TwitchVM") 14 | var twitchAuth: TwitchDeviceAuth = .init() 15 | var deviceCodeInfo: (userCode: String, verificationUri: String)? 16 | 17 | var loggedIn: Bool = true 18 | var streams: [StreamModel] = [] 19 | 20 | // Login 21 | var attempts = 0 22 | let maxAttempts = 25 23 | 24 | // UI 25 | var showOnlyMenu = false 26 | 27 | init() { 28 | print("CREATED TWITCH VM") 29 | loggedIn = (KeychainSwift.getUserID() != nil) 30 | } 31 | 32 | var loading = false { 33 | didSet { 34 | if loading { 35 | // Start the 3.0 sec timeout in case it never stops loading. 36 | DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in 37 | // == true is necessary because loading can be nil. 38 | if self?.loading == true { 39 | self?.logger.log("3.0 seconds timeout for loading ended, toggling it to false") 40 | self?.loading = false 41 | } 42 | } 43 | } 44 | } 45 | } 46 | 47 | func startDeviceAuthorization() async { 48 | do { 49 | attempts = 1 50 | let (deviceCode, userCode, verificationUri) = try await twitchAuth.startDeviceAuthorization() 51 | deviceCodeInfo = (userCode, verificationUri) 52 | await pollForToken(deviceCode: deviceCode) 53 | } catch { 54 | logger.error("Device Authorization Error: \(error.localizedDescription)") 55 | } 56 | } 57 | 58 | private func pollForToken(deviceCode: String) async { 59 | let pollingIntervalNanoseconds: UInt64 = 5_000_000_000 // 5 seconds 60 | 61 | while attempts < maxAttempts { 62 | do { 63 | let accessToken = try await twitchAuth.pollForToken(deviceCode: deviceCode) 64 | let authModel = AuthModel(Constants.clientID, accessToken) 65 | let loginSuccess = KeychainSwift.login(authModel) 66 | loggedIn = loginSuccess 67 | if loginSuccess { 68 | await fetchFollowedStreams() 69 | return 70 | } 71 | } catch { 72 | logger.error("Error Polling for Token: \(error.localizedDescription)") 73 | } 74 | attempts += 1 75 | try? await Task.sleep(nanoseconds: pollingIntervalNanoseconds) 76 | } 77 | 78 | logger.error("Max polling attempts reached or device code expired") 79 | } 80 | 81 | @MainActor 82 | func login() async { 83 | guard let auth = KeychainSwift.getAuth() else { 84 | loggedIn = false 85 | return 86 | } 87 | 88 | logger.log("Logging in with \(auth.accessToken) & \(auth.clientID)") 89 | loggedIn = true 90 | await fetchFollowedStreams() 91 | } 92 | 93 | @MainActor 94 | func logout() { 95 | loggedIn = !KeychainSwift.logout() 96 | streams = [] 97 | deviceCodeInfo = nil 98 | } 99 | 100 | @MainActor 101 | func fetchFollowedStreams() async { 102 | loading = true 103 | logger.info("Fetching followed streams") 104 | 105 | guard let auth: AuthModel = KeychainSwift.getAuth() else { 106 | logger.error("AccessToken + ClientID not found") 107 | loggedIn = false 108 | return 109 | } 110 | 111 | guard let userID: String = KeychainSwift.getUserID() else { 112 | logger.warning("UserID not fetched yet, going to fetch it now.") 113 | guard let userID = await fetchUserID(with: auth.accessToken) else { 114 | return 115 | } 116 | 117 | if KeychainSwift.setUserID(userID) { return await fetchFollowedStreams() } 118 | 119 | return 120 | } 121 | 122 | guard let url = Constants.followedAPIURL(with: userID) else { 123 | logger.error("Invalid endpoint for followed API") 124 | return 125 | } 126 | 127 | var request = URLRequest(url: url) 128 | request.httpMethod = "GET" 129 | request.addValue("Bearer \(auth.accessToken)", forHTTPHeaderField: "Authorization") 130 | request.addValue(auth.clientID, forHTTPHeaderField: "Client-Id") 131 | 132 | do { 133 | let (data, response) = try await URLSession.shared.data(for: request) 134 | guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { 135 | logger.error("Error: Non-200 HTTP response from twitch: \(response)") 136 | return 137 | } 138 | 139 | streams = decode(TwitchResponse.self, from: data)?.data ?? [] 140 | loading = false 141 | } catch { 142 | logger.error("Error fetching streams: \(String(describing: error))") 143 | } 144 | } 145 | } 146 | 147 | extension TwitchVM { 148 | // Helper Functions 149 | @MainActor 150 | func fetchUserID(with accessToken: String) async -> String? { 151 | var request = URLRequest(url: Constants.oauthValidateURL) 152 | request.httpMethod = "GET" 153 | request.addValue("OAuth \(accessToken)", forHTTPHeaderField: "Authorization") 154 | 155 | do { 156 | let (data, response) = try await URLSession.shared.data(for: request) 157 | 158 | guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { 159 | loading = false 160 | loggedIn = false 161 | logger.error("Error: Non-200 HTTP response while validating oauth: \(response)") 162 | return nil 163 | } 164 | 165 | guard let userID = decode(OauthValidate.self, from: data)?.userID else { 166 | logger.error("Error decoding userID") 167 | 168 | return nil 169 | } 170 | return userID 171 | 172 | } catch { 173 | logger.error("Error fetching userID: \(error.localizedDescription)") 174 | return nil 175 | } 176 | } 177 | 178 | // Generic Decoding Function with pretty messages 179 | func decode(_: T.Type, from data: Data) -> T? { 180 | do { 181 | let decodedObject = try JSONDecoder().decode(T.self, from: data) 182 | return decodedObject 183 | } catch let DecodingError.dataCorrupted(context) { 184 | print("Data corrupted: \(context.debugDescription)") 185 | } catch let DecodingError.keyNotFound(key, context) { 186 | print("Key '\(key)' not found: \(context.debugDescription)") 187 | } catch let DecodingError.valueNotFound(value, context) { 188 | print("Value '\(value)' not found: \(context.debugDescription)") 189 | } catch let DecodingError.typeMismatch(type, context) { 190 | print("Type '\(type)' mismatch: \(context.debugDescription)") 191 | } catch { 192 | print("Unknown error: \(error)") 193 | } 194 | return nil 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /NativeTwitch/ViewModifiers/CleanTextFieldStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CleanTextFieldStyle.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CleanTextFieldStyle: ViewModifier { 11 | func body(content: Content) -> some View { 12 | content 13 | .textFieldStyle(.plain) 14 | .padding(6) 15 | .background(.ultraThinMaterial) 16 | .clipShape(.rect(cornerRadius: 8)) 17 | .font(.title3) 18 | } 19 | } 20 | 21 | extension View { 22 | func cleanTextField() -> ModifiedContent { 23 | return modifier( 24 | CleanTextFieldStyle() 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /NativeTwitch/ViewModifiers/LongButtonModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LongButtonModifier.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // MARK: - LongButtonModifier 11 | 12 | struct LongButtonModifier: ViewModifier { 13 | let foreground: Color 14 | let background: Color 15 | let radius: CGFloat 16 | 17 | func body(content: Content) -> some View { 18 | content 19 | .hSpacing(.center) 20 | .padding(8) 21 | .foregroundStyle(foreground) 22 | .background(background) 23 | .clipShape(.rect(cornerRadius: radius)) 24 | .fontWeight(.semibold) 25 | } 26 | } 27 | 28 | extension View { 29 | func longButton( 30 | foreground: Color = .primary, 31 | background: Color = .secondary, 32 | radius: CGFloat = 8.0 33 | ) -> ModifiedContent { 34 | return modifier( 35 | LongButtonModifier( 36 | foreground: foreground, 37 | background: background, 38 | radius: radius 39 | ) 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /NativeTwitch/ViewModifiers/TransitionModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransitionModifier.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BlurReplaceTransition: ViewModifier { 11 | let edge: Edge 12 | func body(content: Content) -> some View { 13 | content 14 | .transition(.move(edge: edge).combined(with: .blurReplace)) 15 | } 16 | } 17 | 18 | extension View { 19 | func blurReplace(edge: Edge = .bottom) -> ModifiedContent { 20 | return modifier(BlurReplaceTransition(edge: edge)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /NativeTwitch/Views/AboutView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutView.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-11-14. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AboutView: View { 11 | var body: some View { 12 | VStack { 13 | Spacer() 14 | 15 | Group { 16 | logoSection 17 | 18 | infoSection 19 | } 20 | 21 | Group { 22 | moreInfoButton 23 | 24 | privacyTOS 25 | } 26 | 27 | Spacer() 28 | } 29 | .frame(width: 280, height: 500 - 42) 30 | .background( 31 | ZStack { 32 | Image(.appIcon) 33 | .resizable() 34 | .scaledToFill() 35 | .blur(radius: 128) 36 | .opacity(0.25) 37 | 38 | VisualEffectBlur(material: .hudWindow, blendingMode: .withinWindow, state: .active) 39 | } 40 | .ignoresSafeArea()) 41 | } 42 | 43 | private var logoSection: some View { 44 | VStack { 45 | Image(.appIcon) 46 | .resizable() 47 | .scaledToFit() 48 | .frame(width: 128) 49 | .padding() 50 | 51 | Text("NativeTwitch") 52 | .font(.title.bold()) 53 | 54 | Text("Version \(Bundle.main.appVersion), \(Bundle.main.appBuild)") 55 | .font(.caption) 56 | .foregroundStyle(.tertiary) 57 | .padding(.bottom) 58 | } 59 | } 60 | 61 | private var infoSection: some View { 62 | HStack { 63 | Spacer() 64 | VStack(alignment: .trailing) { 65 | InfoRow(label: "Build", value: Bundle.main.appBuild) 66 | InfoRow(label: "Github", value: "Aayush9029", link: "https://github.com/Aayush9029") 67 | InfoRow(label: "Designed By", value: "Aayush", link: "https://aayush.art") 68 | InfoRow(label: "Made in", value: "Toronto, CA") 69 | } 70 | .font(.subheadline) 71 | Spacer() 72 | } 73 | } 74 | 75 | private var moreInfoButton: some View { 76 | Link(destination: URL(string: "https://github.com/aayush9029")!) { 77 | Text("More Info...") 78 | } 79 | .buttonStyle(.bordered) 80 | .padding() 81 | } 82 | 83 | // MARK: - TODO CREATE WEBSITES LOL 84 | 85 | private var privacyTOS: some View { 86 | VStack { 87 | Link(destination: URL(string: "https://apps.aayush.art/privacy")!) { 88 | Text("Privacy Policy") 89 | } 90 | Link(destination: Constants.donateLink) { 91 | Text("Support Developer") 92 | } 93 | Link(destination: URL(string: "https://apps.aayush.art")!) { 94 | Text("Other Apps by Developer") 95 | } 96 | } 97 | .underline() 98 | .buttonStyle(.plain) 99 | .font(.subheadline) 100 | .foregroundStyle(.secondary) 101 | } 102 | } 103 | 104 | struct InfoRow: View { 105 | @Environment(\.openURL) var openURL 106 | let label: String 107 | let value: String 108 | var link: String? = nil 109 | @State private var hovered: Bool = false 110 | 111 | var body: some View { 112 | HStack { 113 | Text(label) 114 | HStack { 115 | Text(value) 116 | Spacer() 117 | } 118 | .foregroundStyle(hovered ? .primary : .secondary) 119 | .frame(width: 80) 120 | } 121 | .onTapGesture { 122 | if let link, 123 | let url = URL(string: link) 124 | { 125 | openURL(url) 126 | } 127 | } 128 | .onHover(perform: { hovering in 129 | if link != nil { 130 | withAnimation(.spring) { 131 | hovered = hovering 132 | } 133 | } 134 | }) 135 | } 136 | } 137 | 138 | #Preview { 139 | AboutView() 140 | } 141 | -------------------------------------------------------------------------------- /NativeTwitch/Views/LoginView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoginView.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct LoginView: View { 11 | @Environment(\.openURL) var openURL 12 | @Environment(TwitchVM.self) var twitchVM 13 | @State private var showNote = false 14 | var startedAttempt: Bool { 15 | return twitchVM.attempts < 2 16 | } 17 | 18 | var body: some View { 19 | VStack { 20 | VStack { 21 | if startedAttempt { 22 | ThankYouNote 23 | .blurReplace(edge: .top) 24 | } 25 | }.animation(.easeInOut, value: startedAttempt) 26 | 27 | HStack { 28 | Image(.appIcon) 29 | .resizable() 30 | .scaledToFit() 31 | .frame(width: 96) 32 | VStack(alignment: .leading) { 33 | Text("NativeTwitch") 34 | .font(.title) 35 | .fontWeight(.semibold) 36 | Text("Aayush9029/NativeTwitch") 37 | .foregroundStyle(.tertiary) 38 | } 39 | } 40 | .hSpacing(.leading) 41 | 42 | Group { 43 | Text("1. Get Device Code") 44 | .hSpacing(.leading) 45 | Text("2. Go to: https://www.twitch.tv/activate") 46 | .hSpacing(.leading) 47 | Text("3. Login and enter the Code") 48 | .hSpacing(.leading) 49 | } 50 | .padding(6) 51 | .padding(.horizontal, 8) 52 | .background(.ultraThinMaterial) 53 | .font(.headline) 54 | .clipShape(.rect(cornerRadius: 6)) 55 | 56 | Spacer() 57 | 58 | if let deviceCode = twitchVM.deviceCodeInfo { 59 | VStack(alignment: .leading) { 60 | HStack { 61 | Text("\(twitchVM.attempts)/\(twitchVM.maxAttempts) polling twitch.tv ") 62 | Spacer() 63 | 64 | Image(systemName: "wifi") 65 | .symbolEffect(.variableColor.iterative, isActive: twitchVM.deviceCodeInfo?.userCode != nil) 66 | 67 | }.foregroundStyle(.secondary) 68 | ProgressView(value: Double(twitchVM.attempts / twitchVM.maxAttempts), total: 1.0) 69 | .progressViewStyle(.linear) 70 | .foregroundStyle(.secondary) 71 | .tint(.twitch) 72 | } 73 | 74 | Text(deviceCode.userCode) 75 | .font(.largeTitle.bold()) 76 | .hSpacing(.center) 77 | .padding() 78 | 79 | .padding() 80 | .background(.secondary.opacity(0.125)) 81 | .clipShape(.rect(cornerRadius: 6)) 82 | 83 | Button { 84 | if let url = URL(string: deviceCode.verificationUri) { 85 | openURL(url) 86 | } 87 | } label: { 88 | Label("Continue on twitch.tv", systemImage: "safari") 89 | .longButton(foreground: .white, background: .twitch, radius: 6) 90 | } 91 | .buttonStyle(.plain) 92 | } else { 93 | RefreshDeviceAuthorization 94 | } 95 | } 96 | .xSpacing(.center) 97 | .padding() 98 | .background( 99 | GenerativePreview(shader: .lightGrid) 100 | .blur(radius: 128) 101 | ) 102 | } 103 | 104 | var RefreshDeviceAuthorization: some View { 105 | Button { 106 | Task { 107 | await twitchVM.startDeviceAuthorization() 108 | } 109 | 110 | } label: { 111 | Label("Get Device Code", systemImage: "hands.sparkles") 112 | .longButton(foreground: .twitch, background: .white, radius: 6) 113 | } 114 | .buttonStyle(.plain) 115 | } 116 | 117 | var ThankYouNote: some View { 118 | VStack { 119 | if showNote { 120 | Group { 121 | ScrollView(.vertical, showsIndicators: false) { 122 | VStack(alignment: .leading) { 123 | Text("👋 Hi Person!") 124 | .font(.title.bold()) 125 | Text("I'm genuinely grateful for the opportunity to create valuable tools for amazing individuals like you. Your support, whether through donations, sharing my work, or simply by using it, means everything to me. It enables me to continue doing what I love. Although I can't see all of you (since I don't collect any analytics data), I feel your support in my heart. You might be wondering, 'Why the heck is this guy writing an essay?' Well, I needed to fill this blank space with something, so I thought a thank you note would be fitting. Thank you for inspiring me, Mr. Internet Person.") 126 | .foregroundStyle(.secondary) 127 | 128 | VStack { 129 | Button(action: { openURL(Constants.donateLink) }, label: { 130 | Text("☕\nBuy me a coffee") 131 | .foregroundStyle(.secondary) 132 | .padding(4) 133 | .hSpacing(.center) 134 | .multilineTextAlignment(.center) 135 | .background(.green.opacity(0.125)) 136 | .clipShape(.rect(cornerRadius: 6)) 137 | }) 138 | .buttonStyle(.plain) 139 | } 140 | } 141 | .padding() 142 | } 143 | } 144 | .transition(.move(edge: .top).combined(with: .opacity)) 145 | } else { 146 | Button { 147 | showNote.toggle() 148 | 149 | } label: { 150 | Label("Read Thank You Note.", systemImage: "heart.fill") 151 | .fontWeight(.medium) 152 | .font(.title3) 153 | .foregroundStyle(.primary) 154 | .hSpacing(.center) 155 | .padding() 156 | .background( 157 | GenerativePreview(shader: .animatedGradient) 158 | .blur(radius: 32) 159 | ) 160 | } 161 | .buttonStyle(.plain) 162 | } 163 | } 164 | .animation(.bouncy, value: showNote) 165 | 166 | .background(.thinMaterial) 167 | .clipShape(.rect(cornerRadius: 6)) 168 | .overlay( 169 | RoundedRectangle(cornerRadius: 6) 170 | .stroke(showNote ? .twitch : .gray.opacity(0.25), lineWidth: 2) 171 | .shadow(color: showNote ? .twitch : .red.opacity(0.1), radius: 32) 172 | ) 173 | } 174 | } 175 | 176 | struct CustomTextField: View { 177 | let text: String 178 | @Binding var value: String 179 | 180 | var body: some View { 181 | VStack(alignment: .leading) { 182 | Text(text) 183 | .foregroundStyle(.secondary) 184 | .font(.caption2) 185 | 186 | ZStack(alignment: .trailing) { 187 | SecureField(text, text: $value) 188 | .lineLimit(1) 189 | .cleanTextField() 190 | } 191 | } 192 | } 193 | } 194 | 195 | #Preview { 196 | LoginView() 197 | .frame(width: 320, height: 536) 198 | .environment(TwitchVM()) 199 | } 200 | -------------------------------------------------------------------------------- /NativeTwitch/Views/SingleStreamRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SingleStreamRow.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SingleStreamRow: View { 11 | @Environment(\.openURL) var openURL 12 | @State private var hovered = false 13 | 14 | let stream: StreamModel 15 | 16 | init(_ stream: StreamModel) { 17 | self.stream = stream 18 | } 19 | 20 | var body: some View { 21 | VStack(alignment: .leading) { 22 | Group { 23 | if !hovered { 24 | HStack { 25 | BadgeView( 26 | symbol: "clock", 27 | symbolColor: .green 28 | ) { 29 | Text(stream.startedDate, style: .relative) 30 | } 31 | 32 | Spacer() 33 | 34 | BadgeView( 35 | symbol: "person.2", 36 | symbolColor: .red 37 | ) { 38 | Text(stream.viewers) 39 | } 40 | } 41 | .padding(4) 42 | .blurReplace(edge: .top) 43 | Spacer() 44 | VStack(alignment: .leading) { 45 | Text(stream.userName) 46 | .font(.headline) 47 | Text(stream.title) 48 | .font(.subheadline) 49 | .multilineTextAlignment(.leading) 50 | .fontWeight(.medium) 51 | .hSpacing(.leading) 52 | } 53 | .shadow(radius: 6) 54 | .padding(6) 55 | .background( 56 | RoundedRectangle(cornerRadius: 4) 57 | .fill(.thickMaterial) 58 | .mask(LinearGradient.bottomMasked) 59 | ) 60 | .blurReplace(edge: .bottom) 61 | } 62 | } 63 | } 64 | .xSpacing(.center) 65 | .background( 66 | ScalledToFillImage(stream.thumbnail) 67 | ) 68 | .clipShape(.rect(cornerRadius: 4)) 69 | .overlay( 70 | RoundedRectangle(cornerRadius: 4) 71 | .stroke( 72 | hovered ? .twitch : .gray.opacity(0.25), 73 | lineWidth: 2 74 | ) 75 | ) 76 | .frame(height: 164) 77 | .onHover { hovering in 78 | withAnimation(.easeInOut) { 79 | hovered = hovering 80 | } 81 | } 82 | .contextMenu(menuItems: { 83 | Button("Open Stream") { 84 | openURL(stream.streamURL) 85 | } 86 | Button("Popout Chat") { 87 | openURL(stream.chatURL) 88 | } 89 | 90 | }) 91 | } 92 | 93 | @ViewBuilder 94 | func ScalledToFillImage(_ url: URL?) -> some View { 95 | AsyncImage(url: url) { phase in 96 | switch phase { 97 | case .empty: 98 | ProgressView() 99 | case .success(let image): 100 | image.resizable() 101 | case .failure: 102 | Image(systemName: "photo") 103 | @unknown default: 104 | EmptyView() 105 | } 106 | } 107 | .scaledToFill() 108 | } 109 | } 110 | 111 | #Preview { 112 | SingleStreamRow(.xQc) 113 | .frame(width: 320, height: 480) 114 | .padding() 115 | } 116 | -------------------------------------------------------------------------------- /NativeTwitch/Views/StreamsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StreamsView.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StreamsView: View { 11 | let streams: [StreamModel] 12 | 13 | init(_ streams: [StreamModel]) { 14 | self.streams = streams 15 | } 16 | 17 | var body: some View { 18 | ScrollView(.vertical, showsIndicators: false) { 19 | LazyVStack { 20 | ForEach(streams) { stream in 21 | SingleStreamRow(stream) 22 | } 23 | } 24 | .scrollTargetLayout() 25 | .padding(8) 26 | } 27 | .scrollTargetBehavior(.viewAligned) 28 | } 29 | } 30 | 31 | #Preview { 32 | StreamsView([.xQc, .pokelawls]) 33 | .frame(width: 360, height: 480) 34 | } 35 | -------------------------------------------------------------------------------- /NativeTwitch/Views/Subviews/BadgeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BadgeView.swift 3 | // NativeTwitch 4 | // 5 | // Created by Aayush Pokharel on 2023-12-16. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BadgeView: View { 11 | var symbol: String 12 | var symbolColor: Color 13 | var content: () -> Content 14 | 15 | init(symbol: String, symbolColor: Color, @ViewBuilder content: @escaping () -> Content) { 16 | self.symbol = symbol 17 | self.symbolColor = symbolColor 18 | self.content = content 19 | } 20 | 21 | var body: some View { 22 | HStack { 23 | Image(systemName: symbol) 24 | .foregroundStyle(symbolColor) 25 | .shadow(color: symbolColor.opacity(0.5), radius: 6) 26 | .symbolVariant(.fill) 27 | content() 28 | .bold() 29 | } 30 | .font(.caption) 31 | .padding(6) 32 | .background(.thinMaterial) 33 | .clipShape(RoundedRectangle(cornerRadius: 4)) 34 | } 35 | } 36 | 37 | #Preview("Badge View") { 38 | VStack { 39 | BadgeView(symbol: "circle", symbolColor: .red) { 40 | Text("10k") 41 | } 42 | .padding() 43 | .background(Color.gray.opacity(0.25)) 44 | 45 | BadgeView(symbol: "clock", symbolColor: .red) { 46 | Text(Date().addingTimeInterval(-2400), style: .relative) 47 | } 48 | .padding() 49 | .background(Color.gray.opacity(0.25)) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

NativeTwitch v4

3 | 4 | 5 | 6 | _Native, Opensource Twitch app for your mac._ 7 | 8 | Buy Me coffee  Download App 9 |
10 |
11 | 12 | 13 | 14 |
15 | 16 | --- 17 | 18 | ### Check [Releases](https://github.com/Aayush9029/NativeTwitch/releases) for release notes. 19 | 20 | --- 21 | 22 | Icon by [Orcher](https://macosicons.com/#/u/Orcher) 23 | --------------------------------------------------------------------------------