├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── crash_report.md │ └── feature_request.md ├── .gitignore ├── .swiftlint.yml ├── Assets ├── PlayIcon_x1.png ├── icon.png ├── icon.sketch ├── icon.svg └── icons.sketch ├── Doughnut.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── xcshareddata │ └── xcschemes │ └── Doughnut.xcscheme ├── Doughnut.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── IDEWorkspaceChecks.plist │ └── WorkspaceSettings.xcsettings ├── Doughnut ├── AppDelegate.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon_1024x1024.png │ │ ├── Icon_128x128.png │ │ ├── Icon_16x16.png │ │ ├── Icon_256x256-1.png │ │ ├── Icon_256x256.png │ │ ├── Icon_32x32-1.png │ │ ├── Icon_32x32.png │ │ ├── Icon_512x512-1.png │ │ ├── Icon_512x512.png │ │ └── Icon_64x64.png │ ├── CloseIcon.imageset │ │ ├── Contents.json │ │ ├── close_x1.png │ │ └── close_x2.png │ ├── Contents.json │ ├── DownloadsIcon.imageset │ │ ├── Contents.json │ │ ├── downloads_icon_x1.png │ │ └── downloads_icon_x2.png │ ├── FilterActive.imageset │ │ ├── Contents.json │ │ └── filter_active.svg │ ├── FilterInactive.imageset │ │ ├── Contents.json │ │ └── filter_inactive.svg │ ├── ForwardIcon.imageset │ │ ├── Contents.json │ │ ├── forward_icon_1x.png │ │ ├── forward_icon_2x.png │ │ └── forward_icon_3x.png │ ├── PauseIcon.imageset │ │ ├── Contents.json │ │ ├── pause_icon_1x.png │ │ ├── pause_icon_2x.png │ │ └── pause_icon_3x.png │ ├── PlaceholderIcon.imageset │ │ ├── Contents.json │ │ ├── icon_subtle-1.png │ │ └── icon_subtle.png │ ├── PlayIcon.imageset │ │ ├── Contents.json │ │ ├── play_icon_1x.png │ │ ├── play_icon_2x.png │ │ └── play_icon_3x.png │ ├── PodcastFilter.imageset │ │ ├── Contents.json │ │ └── PodcastFilter@2x.png │ ├── PodcastFilterActive.imageset │ │ ├── Contents.json │ │ └── PodcastFilterActive@2x.png │ ├── PodcastPlaceholder.imageset │ │ ├── Contents.json │ │ ├── podcast_placeholder_1x.png │ │ ├── podcast_placeholder_2x.png │ │ └── podcast_placeholder_3x.png │ ├── PrefAppIcon │ │ ├── Contents.json │ │ ├── Icon_Big_Sur.imageset │ │ │ ├── Contents.json │ │ │ ├── Icon_Big_Sur.png │ │ │ └── Icon_Big_Sur@2x.png │ │ └── Icon_Catalina.imageset │ │ │ ├── Contents.json │ │ │ ├── Icon_Catalina.png │ │ │ └── Icon_Catalina@2x.png │ ├── PrefIcon │ │ ├── Contents.json │ │ ├── Library.imageset │ │ │ ├── Contents.json │ │ │ ├── PrefLibrary.png │ │ │ ├── PrefLibrary@2x.png │ │ │ ├── PrefLibrary_Dark.png │ │ │ └── PrefLibrary_Dark@2x.png │ │ └── Playback.imageset │ │ │ ├── Contents.json │ │ │ ├── PrefPlayback.png │ │ │ └── PrefPlayback@2x.png │ ├── ReverseIcon.imageset │ │ ├── Contents.json │ │ ├── reverse_icon_1x.png │ │ ├── reverse_icon_2x.png │ │ └── reverse_icon_3x.png │ └── ViewBackground.colorset │ │ └── Contents.json ├── Base.lproj │ ├── Main.storyboard │ └── MainMenu.storyboard ├── ControlMenuProvider.swift ├── CrashReport │ ├── CrashReport.storyboard │ ├── CrashReportViewController.swift │ ├── CrashReportWindowController.swift │ └── CrashReporter.swift ├── Doughnut-Bridging-Header.h ├── Doughnut-Debug.entitlements ├── Doughnut-Release.entitlements ├── DoughnutApp.swift ├── Info.plist ├── Library │ ├── Episode.swift │ ├── Library.swift │ ├── MarkupGenerator.swift │ ├── Migrations.swift │ ├── Podcast.swift │ ├── Storage.swift │ ├── TaskQueue.swift │ ├── Tasks │ │ └── EpisodeDownloadTask.swift │ └── Utils.swift ├── Player │ ├── Player+NowPlayingInfoCenter.swift │ ├── Player+RemoteCommandCenter.swift │ └── Player.swift ├── Preference │ ├── PrefAdvancedViewController.swift │ ├── PrefGeneralViewController.swift │ ├── PrefLibraryViewController.swift │ ├── PrefPlaybackViewController.swift │ ├── Preference.swift │ └── Preferences.storyboard ├── Resources │ ├── .gitkeep │ ├── AppIcon_Big_Sur.icns │ ├── Credits.rtf │ ├── detail.css │ ├── detail.js │ └── dsa_pub.pem ├── Utilities │ ├── CMTime+Extensions.swift │ ├── ModalSheetSegue.swift │ ├── NSAppearance+Extensions.swift │ ├── NSButton+Extensions.swift │ ├── NSImage+Extensions.swift │ ├── NSMenu+Extensions.swift │ ├── NSTableView+Extensions.swift │ ├── NSView+Extensions.swift │ └── OSLog+Extensions.swift ├── View Controllers │ ├── DetailViewController.swift │ ├── EpisodeFilterViewController.swift │ ├── EpisodeViewController.swift │ ├── PodcastViewController.swift │ ├── SubscribeViewController.swift │ ├── TasksViewController.swift │ └── ViewController.swift ├── Views │ ├── ActivityIndicator.swift │ ├── BackgroundView.swift │ ├── BaseTableView.swift │ ├── DetailWebView.swift │ ├── EpisodeCellView.swift │ ├── PlayerView.swift │ ├── PodcastCellView.swift │ ├── PodcastSearchField.swift │ ├── SeekSlider.swift │ ├── SortingMenuProvider.swift │ └── TaskManagerView.swift ├── WindowController+Toolbar.swift ├── WindowController.swift └── Windows │ ├── EpisodeInfo.storyboard │ ├── PodcastInfo.storyboard │ ├── ShowEpisodeWindow.swift │ └── ShowPodcastWindow.swift ├── DoughnutTests ├── .swiftlint.yml ├── DoughnutTestCase.swift ├── Info.plist ├── LibraryTests │ ├── Fixtures │ │ ├── ValidFeed.xml │ │ ├── ValidFeedx3.xml │ │ ├── enclosure.mp3 │ │ └── image.jpg │ ├── LibrarySpyDelegate.swift │ ├── LibraryTestCase.swift │ ├── LibraryTestsWithSubscription.swift │ ├── LibraryTestsWithoutSubscriptions.swift │ └── LibraryUtils.swift └── ModelTests │ ├── EpisodeModelTests.swift │ ├── Fixtures │ └── ModelTests.dnl │ ├── ModelTestCase.swift │ └── PodcastModelTests.swift ├── DoughnutUITests ├── DoughnutUITests.swift ├── Info.plist └── PodcastUITests.swift ├── LICENSE ├── Podfile ├── Podfile.lock ├── README.md ├── appcast.xml └── screenshot.png /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug to help us improve Doughnut. 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | 13 | 14 | ## Steps to Reproduce: 15 | 16 | 17 | 18 | 1. ... 19 | 2. ... 20 | 3. ... 21 | 22 | **Expected results:** 23 | 24 | **Actual results:** 25 | 26 | ## Additional Information: 27 | 28 | 29 | 30 | - Doughnut Version: [e.g. 1.1.1 (58)] 31 | - macOS Version: [e.g. 12.3 (21E230)] 32 | - Safari Version: [e.g. 15.4 (17613.1.17.1.6)] 33 | 34 | 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/crash_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Crash Report 3 | about: Report a crash problem. 4 | title: "Crash: " 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | 10 | 11 | ## Steps to Reproduce: 12 | 13 | 14 | 15 | 1. ... 16 | 2. ... 17 | 3. ... 18 | 19 | **Crash Report:** 20 | 21 |
22 | Crash Report 23 | 24 | ``` 25 | ``` 26 |
27 | 28 | ## Additional Information: 29 | 30 | 31 | 32 | - Safari Version: [e.g. 15.4 (17613.1.17.1.6)] 33 | 34 | 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest a new feature for Doughnut. 4 | title: "FR: " 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | ## Build generated 4 | build/ 5 | DerivedData/ 6 | 7 | ## Various settings 8 | *.pbxuser 9 | !default.pbxuser 10 | *.mode1v3 11 | !default.mode1v3 12 | *.mode2v3 13 | !default.mode2v3 14 | *.perspectivev3 15 | !default.perspectivev3 16 | xcuserdata/ 17 | 18 | ## Other 19 | *.moved-aside 20 | *.xccheckout 21 | *.xcscmblueprint 22 | 23 | ## Obj-C/Swift specific 24 | *.hmap 25 | *.ipa 26 | *.dSYM.zip 27 | *.dSYM 28 | 29 | ## Playgrounds 30 | timeline.xctimeline 31 | playground.xcworkspace 32 | 33 | # Swift Package Manager 34 | # 35 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 36 | # Packages/ 37 | # Package.pins 38 | # Package.resolved 39 | .build/ 40 | 41 | # CocoaPods 42 | # 43 | # We recommend against adding the Pods directory to your .gitignore. However 44 | # you should judge for yourself, the pros and cons are mentioned at: 45 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 46 | # 47 | Pods/ 48 | 49 | # Carthage 50 | # 51 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 52 | Carthage/Checkouts 53 | 54 | Carthage/Build 55 | 56 | # fastlane 57 | # 58 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 59 | # screenshots whenever they are needed. 60 | # For more information about the recommended setup visit: 61 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 62 | 63 | fastlane/report.xml 64 | fastlane/Preview.html 65 | fastlane/screenshots 66 | fastlane/test_output 67 | 68 | Releases -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/airbnb/swift 2 | 3 | only_rules: 4 | - closure_spacing 5 | - colon 6 | - empty_enum_arguments 7 | - extension_access_modifier 8 | - fatal_error_message 9 | - file_header 10 | # - force_cast 11 | - force_try 12 | # - force_unwrapping 13 | # - implicitly_unwrapped_optional 14 | - generic_type_name 15 | - legacy_cggeometry_functions 16 | - legacy_constant 17 | - legacy_constructor 18 | - legacy_nsgeometry_functions 19 | - operator_usage_whitespace 20 | - pattern_matching_keywords 21 | - redundant_string_enum_value 22 | - redundant_void_return 23 | - return_arrow_whitespace 24 | - sorted_imports 25 | - switch_case_alignment 26 | - trailing_comma 27 | - trailing_newline 28 | - trailing_semicolon 29 | - trailing_whitespace 30 | - type_name 31 | - unused_closure_parameter 32 | - unused_optional_binding 33 | - vertical_whitespace 34 | - void_return 35 | - custom_rules 36 | 37 | excluded: 38 | - Pods 39 | 40 | colon: 41 | apply_to_dictionaries: true 42 | 43 | indentation: 2 44 | 45 | trailing_comma: 46 | mandatory_comma: true 47 | 48 | file_header: 49 | required_pattern: | 50 | \/\* 51 | \ \* Doughnut Podcast Client 52 | \ \* Copyright \(C\) 2017 - 2022 Chris Dyer 53 | \ \* 54 | \ \* This program is free software: you can redistribute it and\/or modify 55 | \ \* it under the terms of the GNU General Public License as published by 56 | \ \* the Free Software Foundation, either version 3 of the License, or 57 | \ \* \(at your option\) any later version\. 58 | \ \* 59 | \ \* This program is distributed in the hope that it will be useful, 60 | \ \* but WITHOUT ANY WARRANTY; without even the implied warranty of 61 | \ \* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE\. See the 62 | \ \* GNU General Public License for more details\. 63 | \ \* 64 | \ \* You should have received a copy of the GNU General Public License 65 | \ \* along with this program\. If not, see \. 66 | 67 | custom_rules: 68 | no_objcMembers: 69 | name: "@objcMembers" 70 | regex: "@objcMembers" 71 | message: "Explicitly use @objc on each member you want to expose to Objective-C" 72 | severity: error 73 | no_direct_standard_out_logs: 74 | name: "Writing log messages directly to standard out is disallowed" 75 | regex: "(\\bprint|\\bdebugPrint|\\bdump|Swift\\.print|Swift\\.debugPrint|Swift\\.dump)\\s*\\(" 76 | match_kinds: 77 | - identifier 78 | message: "Don't commit `print(…)`, `debugPrint(…)`, or `dump(…)` as they write to standard out in release. Either log to a dedicated logging system or silence this warning in debug-only scenarios explicitly using `// swiftlint:disable:next no_direct_standard_out_logs`" 79 | severity: warning 80 | -------------------------------------------------------------------------------- /Assets/PlayIcon_x1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Assets/PlayIcon_x1.png -------------------------------------------------------------------------------- /Assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Assets/icon.png -------------------------------------------------------------------------------- /Assets/icon.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Assets/icon.sketch -------------------------------------------------------------------------------- /Assets/icons.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Assets/icons.sketch -------------------------------------------------------------------------------- /Doughnut.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Doughnut.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Doughnut.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Doughnut.xcodeproj/xcshareddata/xcschemes/Doughnut.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 51 | 52 | 53 | 54 | 58 | 59 | 60 | 61 | 63 | 69 | 70 | 71 | 73 | 79 | 80 | 81 | 82 | 83 | 93 | 95 | 101 | 102 | 103 | 104 | 110 | 112 | 118 | 119 | 120 | 121 | 123 | 124 | 127 | 128 | 129 | -------------------------------------------------------------------------------- /Doughnut.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Doughnut.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Doughnut.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x9B", 9 | "green" : "0x46", 10 | "red" : "0xFA" 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" : "0xA2", 27 | "green" : "0x58", 28 | "red" : "0xF5" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Doughnut/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_32x32-1.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_64x64.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_256x256-1.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_512x512-1.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_1024x1024.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_1024x1024.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_128x128.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_16x16.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_256x256-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_256x256-1.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_256x256.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_32x32-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_32x32-1.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_32x32.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_512x512-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_512x512-1.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_512x512.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/AppIcon.appiconset/Icon_64x64.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/CloseIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "close_x1.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "close_x2.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/CloseIcon.imageset/close_x1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/CloseIcon.imageset/close_x1.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/CloseIcon.imageset/close_x2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/CloseIcon.imageset/close_x2.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/DownloadsIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "downloads_icon_x1.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "downloads_icon_x2.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/DownloadsIcon.imageset/downloads_icon_x1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/DownloadsIcon.imageset/downloads_icon_x1.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/DownloadsIcon.imageset/downloads_icon_x2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/DownloadsIcon.imageset/downloads_icon_x2.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/FilterActive.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "filter_active.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/FilterActive.imageset/filter_active.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Shape 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/FilterInactive.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "filter_inactive.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/FilterInactive.imageset/filter_inactive.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Shape 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/ForwardIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "forward_icon_1x.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "forward_icon_2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "forward_icon_3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/ForwardIcon.imageset/forward_icon_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/ForwardIcon.imageset/forward_icon_1x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/ForwardIcon.imageset/forward_icon_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/ForwardIcon.imageset/forward_icon_2x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/ForwardIcon.imageset/forward_icon_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/ForwardIcon.imageset/forward_icon_3x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PauseIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "pause_icon_1x.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "pause_icon_2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "pause_icon_3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PauseIcon.imageset/pause_icon_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PauseIcon.imageset/pause_icon_1x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PauseIcon.imageset/pause_icon_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PauseIcon.imageset/pause_icon_2x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PauseIcon.imageset/pause_icon_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PauseIcon.imageset/pause_icon_3x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PlaceholderIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "icon_subtle-1.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "icon_subtle.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PlaceholderIcon.imageset/icon_subtle-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PlaceholderIcon.imageset/icon_subtle-1.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PlaceholderIcon.imageset/icon_subtle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PlaceholderIcon.imageset/icon_subtle.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PlayIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "play_icon_1x.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "play_icon_2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "play_icon_3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PlayIcon.imageset/play_icon_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PlayIcon.imageset/play_icon_1x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PlayIcon.imageset/play_icon_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PlayIcon.imageset/play_icon_2x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PlayIcon.imageset/play_icon_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PlayIcon.imageset/play_icon_3x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PodcastFilter.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "PodcastFilter@2x.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PodcastFilter.imageset/PodcastFilter@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PodcastFilter.imageset/PodcastFilter@2x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PodcastFilterActive.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "PodcastFilterActive@2x.png", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "template-rendering-intent" : "template" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PodcastFilterActive.imageset/PodcastFilterActive@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PodcastFilterActive.imageset/PodcastFilterActive@2x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PodcastPlaceholder.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "podcast_placeholder_1x.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "podcast_placeholder_2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "podcast_placeholder_3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PodcastPlaceholder.imageset/podcast_placeholder_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PodcastPlaceholder.imageset/podcast_placeholder_1x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PodcastPlaceholder.imageset/podcast_placeholder_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PodcastPlaceholder.imageset/podcast_placeholder_2x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PodcastPlaceholder.imageset/podcast_placeholder_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PodcastPlaceholder.imageset/podcast_placeholder_3x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefAppIcon/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "properties" : { 7 | "provides-namespace" : true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefAppIcon/Icon_Big_Sur.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon_Big_Sur.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "Icon_Big_Sur@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefAppIcon/Icon_Big_Sur.imageset/Icon_Big_Sur.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PrefAppIcon/Icon_Big_Sur.imageset/Icon_Big_Sur.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefAppIcon/Icon_Big_Sur.imageset/Icon_Big_Sur@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PrefAppIcon/Icon_Big_Sur.imageset/Icon_Big_Sur@2x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefAppIcon/Icon_Catalina.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon_Catalina.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "Icon_Catalina@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefAppIcon/Icon_Catalina.imageset/Icon_Catalina.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PrefAppIcon/Icon_Catalina.imageset/Icon_Catalina.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefAppIcon/Icon_Catalina.imageset/Icon_Catalina@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PrefAppIcon/Icon_Catalina.imageset/Icon_Catalina@2x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefIcon/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | }, 6 | "properties" : { 7 | "provides-namespace" : true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefIcon/Library.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "PrefLibrary.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "filename" : "PrefLibrary_Dark.png", 16 | "idiom" : "universal", 17 | "scale" : "1x" 18 | }, 19 | { 20 | "filename" : "PrefLibrary@2x.png", 21 | "idiom" : "universal", 22 | "scale" : "2x" 23 | }, 24 | { 25 | "appearances" : [ 26 | { 27 | "appearance" : "luminosity", 28 | "value" : "dark" 29 | } 30 | ], 31 | "filename" : "PrefLibrary_Dark@2x.png", 32 | "idiom" : "universal", 33 | "scale" : "2x" 34 | }, 35 | { 36 | "idiom" : "universal", 37 | "scale" : "3x" 38 | }, 39 | { 40 | "appearances" : [ 41 | { 42 | "appearance" : "luminosity", 43 | "value" : "dark" 44 | } 45 | ], 46 | "idiom" : "universal", 47 | "scale" : "3x" 48 | } 49 | ], 50 | "info" : { 51 | "author" : "xcode", 52 | "version" : 1 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefIcon/Library.imageset/PrefLibrary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PrefIcon/Library.imageset/PrefLibrary.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefIcon/Library.imageset/PrefLibrary@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PrefIcon/Library.imageset/PrefLibrary@2x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefIcon/Library.imageset/PrefLibrary_Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PrefIcon/Library.imageset/PrefLibrary_Dark.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefIcon/Library.imageset/PrefLibrary_Dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PrefIcon/Library.imageset/PrefLibrary_Dark@2x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefIcon/Playback.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "PrefPlayback.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "PrefPlayback@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "author" : "xcode", 20 | "version" : 1 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefIcon/Playback.imageset/PrefPlayback.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PrefIcon/Playback.imageset/PrefPlayback.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/PrefIcon/Playback.imageset/PrefPlayback@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/PrefIcon/Playback.imageset/PrefPlayback@2x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/ReverseIcon.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "reverse_icon_1x.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "reverse_icon_2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "reverse_icon_3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | }, 23 | "properties" : { 24 | "template-rendering-intent" : "template" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/ReverseIcon.imageset/reverse_icon_1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/ReverseIcon.imageset/reverse_icon_1x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/ReverseIcon.imageset/reverse_icon_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/ReverseIcon.imageset/reverse_icon_2x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/ReverseIcon.imageset/reverse_icon_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Assets.xcassets/ReverseIcon.imageset/reverse_icon_3x.png -------------------------------------------------------------------------------- /Doughnut/Assets.xcassets/ViewBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 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" : "0x45", 27 | "green" : "0x44", 28 | "red" : "0x48" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Doughnut/ControlMenuProvider.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Foundation 20 | 21 | final class ControlMenuProvider { 22 | 23 | static let shared = ControlMenuProvider() 24 | 25 | private init() { } 26 | 27 | func buildControlMenuItems(isForDockMenu: Bool) -> [NSMenuItem] { 28 | var menuItems = [NSMenuItem]() 29 | 30 | let player = Player.global 31 | let isCurrentlyPlaying = player.currentEpisode != nil 32 | 33 | // Now Playing 34 | if 35 | isForDockMenu, 36 | isCurrentlyPlaying, 37 | let currentEpisode = player.currentEpisode, 38 | !currentEpisode.title.isEmpty 39 | { 40 | let formatNowPlayingTitle: (Episode) -> String = { episode in 41 | if let podcastTitle = episode.podcast?.title { 42 | return "\(episode.title) - \(podcastTitle)" 43 | } else { 44 | return episode.title 45 | } 46 | } 47 | 48 | menuItems.append( 49 | NSMenuItem( 50 | title: "Now Playing", 51 | action: nil, 52 | keyEquivalent: "" 53 | ) 54 | ) 55 | 56 | let nowPlayingItem = NSMenuItem( 57 | title: formatNowPlayingTitle(currentEpisode), 58 | action: nil, 59 | keyEquivalent: "" 60 | ) 61 | nowPlayingItem.indentationLevel = 1 62 | menuItems.append(nowPlayingItem) 63 | 64 | menuItems.append(.separator()) 65 | } 66 | 67 | // Play / Pause 68 | let togglePlayItem = NSMenuItem( 69 | title: player.isPlaying ? "Pause" : "Play", 70 | action: #selector(playerToggle(_:)), 71 | keyEquivalent: " " 72 | ) 73 | togglePlayItem.target = self 74 | togglePlayItem.keyEquivalentModifierMask = [] 75 | togglePlayItem.isEnabled = isCurrentlyPlaying 76 | menuItems.append(togglePlayItem) 77 | 78 | // Skip / Rewind 79 | if !isForDockMenu || isCurrentlyPlaying { 80 | let skipForwardDuration = Preference.double(for: Preference.Key.skipForwardDuration) 81 | let skipBackawrdDuration = Preference.double(for: Preference.Key.skipBackDuration) 82 | 83 | let formatSkipping: (String, TimeInterval) -> String = { title, duration in 84 | if duration >= 60 { 85 | return "\(title) \(Int(duration / 60)) Minutes" 86 | } else { 87 | return "\(title) \(Int(duration)) Seconds" 88 | } 89 | } 90 | 91 | // Skip 92 | let skipForwardItem = NSMenuItem( 93 | title: formatSkipping("Skip", skipForwardDuration), 94 | action: #selector(playerForward(_:)), 95 | keyEquivalent: String(Unicode.Scalar(NSRightArrowFunctionKey)!) 96 | ) 97 | skipForwardItem.target = self 98 | skipForwardItem.isEnabled = isCurrentlyPlaying 99 | menuItems.append(skipForwardItem) 100 | 101 | // Rewind 102 | let skipBackwardItem = NSMenuItem( 103 | title: formatSkipping("Rewind", skipBackawrdDuration), 104 | action: #selector(playerBackward(_:)), 105 | keyEquivalent: String(Unicode.Scalar(NSLeftArrowFunctionKey)!) 106 | ) 107 | skipBackwardItem.target = self 108 | skipBackwardItem.isEnabled = isCurrentlyPlaying 109 | menuItems.append(skipBackwardItem) 110 | } 111 | 112 | if !isForDockMenu { 113 | // Separator 114 | menuItems.append(.separator()) 115 | 116 | // Volume Up 117 | let volumeUpItem = NSMenuItem( 118 | title: "Increase Volume", 119 | action: #selector(volumeUp(_:)), 120 | keyEquivalent: String(Unicode.Scalar(NSUpArrowFunctionKey)!) 121 | ) 122 | volumeUpItem.target = self 123 | menuItems.append(volumeUpItem) 124 | 125 | // Volume Down 126 | let volumeDownItem = NSMenuItem( 127 | title: "Decrease Volume", 128 | action: #selector(volumeDown(_:)), 129 | keyEquivalent: String(Unicode.Scalar(NSDownArrowFunctionKey)!) 130 | ) 131 | volumeDownItem.target = self 132 | menuItems.append(volumeDownItem) 133 | } 134 | 135 | return menuItems 136 | } 137 | 138 | @objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { 139 | return menuItem.isEnabled 140 | } 141 | 142 | // Actions 143 | 144 | @objc func playerBackward(_ sender: Any) { 145 | Player.global.skipBack() 146 | } 147 | 148 | @objc func playerToggle(_ sender: Any) { 149 | Player.global.togglePlay() 150 | } 151 | 152 | @objc func playerForward(_ sender: Any) { 153 | Player.global.skipAhead() 154 | } 155 | 156 | @objc func volumeUp(_ sender: Any) { 157 | let current = Player.global.volume 158 | Player.global.volume = min(current + 0.1, 1.0) 159 | } 160 | 161 | @objc func volumeDown(_ sender: Any) { 162 | let current = Player.global.volume 163 | Player.global.volume = max(current - 0.1, 0.0) 164 | } 165 | 166 | } 167 | -------------------------------------------------------------------------------- /Doughnut/CrashReport/CrashReportViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import AppKit 20 | 21 | final class CrashReportViewController: NSViewController { 22 | 23 | @IBOutlet private weak var contentTextView: NSTextView! 24 | 25 | override func viewDidLoad() { 26 | super.viewDidLoad() 27 | 28 | contentTextView.font = NSFont.userFixedPitchFont(ofSize: 12) 29 | contentTextView.textContainerInset = NSSize(width: 4, height: 4) 30 | } 31 | 32 | override func viewWillAppear() { 33 | super.viewWillAppear() 34 | 35 | view.window?.level = .modalPanel 36 | view.window?.isReleasedWhenClosed = true 37 | view.window?.center() 38 | } 39 | 40 | func setCrashContent(_ content: String) { 41 | contentTextView.string = content 42 | } 43 | 44 | @IBAction private func sendCrashLog(_ sender: Any) { 45 | if let crashReportURLStr = Bundle.main.infoDictionary?["DoughnutCrashReportURL"] as? String, 46 | let crashReportURL = URL(string: crashReportURLStr) 47 | { 48 | NSWorkspace.shared.open(crashReportURL) 49 | } 50 | } 51 | 52 | @IBAction private func dismissCrashReport(_ sender: Any) { 53 | view.window?.windowController?.close() 54 | } 55 | 56 | } 57 | 58 | final class TroubleshootingViewController: NSViewController { 59 | 60 | @IBOutlet weak var resetPreferencesButton: NSButton! 61 | @IBOutlet weak var relocateLibraryButton: NSButton! 62 | 63 | override func viewDidLoad() { 64 | super.viewDidLoad() 65 | resetPreferencesButton.setTitleColor(NSColor.systemRed) 66 | } 67 | 68 | @IBAction private func troubleshootingResetPreferences(_ sender: Any) { 69 | let alert = NSAlert() 70 | alert.addButton(withTitle: "Cancel") 71 | alert.addButton(withTitle: "Confirm") 72 | alert.messageText = "Are you sure you want to restore all preferences to their default settings?" 73 | alert.informativeText = "You can’t undo this action." 74 | 75 | guard alert.runModal() == .alertSecondButtonReturn else { 76 | return 77 | } 78 | 79 | dismiss(self) 80 | 81 | UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!) 82 | UserDefaults.standard.synchronize() 83 | 84 | promptRestart() 85 | } 86 | 87 | @IBAction private func troubleshootingRelocateLibrary(_ sender: Any) { 88 | let panel = NSOpenPanel() 89 | panel.canChooseDirectories = true 90 | panel.canChooseFiles = false 91 | panel.allowsMultipleSelection = false 92 | 93 | guard panel.runModal() == .OK, let url = panel.url else { 94 | return 95 | } 96 | 97 | dismiss(self) 98 | 99 | Preference.set(url, for: Preference.Key.libraryPath) 100 | 101 | promptRestart() 102 | } 103 | 104 | private func promptRestart() { 105 | let alert = NSAlert() 106 | alert.addButton(withTitle: "OK") 107 | alert.messageText = "Doughnut Will Restart" 108 | alert.informativeText = "Doughnut will restart to apply these changes." 109 | 110 | alert.runModal() 111 | 112 | let task = Process() 113 | 114 | var args = [String]() 115 | args.append("-c") 116 | args.append("sleep 0.2; open \"\(Bundle.main.bundlePath)\"") 117 | 118 | task.launchPath = "/bin/sh" 119 | task.arguments = args 120 | task.launch() 121 | NSApp.terminate(self) 122 | } 123 | 124 | } 125 | -------------------------------------------------------------------------------- /Doughnut/CrashReport/CrashReportWindowController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import AppKit 20 | 21 | final class CrashReportWindowController: NSWindowController, NSWindowDelegate { 22 | 23 | private var crashReportViewController: CrashReportViewController { 24 | return contentViewController as! CrashReportViewController 25 | } 26 | 27 | static func instantiateFromMainStoryboard() -> Self? { 28 | return NSStoryboard(name: "CrashReport", bundle: nil).instantiateInitialController() as? Self 29 | } 30 | 31 | func setCrashContent(_ content: String) { 32 | crashReportViewController.setCrashContent(content) 33 | } 34 | 35 | func windowWillClose(_ notification: Notification) { 36 | NSApp.stopModal(withCode: .OK) 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /Doughnut/CrashReport/CrashReporter.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Foundation 20 | import OSLog 21 | 22 | import CrashReporter 23 | 24 | final class CrashReporter { 25 | 26 | static var shared: CrashReporter { 27 | return sharedInstance 28 | } 29 | 30 | private static var sharedInstance = CrashReporter() 31 | private static let log = OSLog.main(category: "CrashReporter") 32 | 33 | private var plCrashReporter: PLCrashReporter? 34 | 35 | private init() { 36 | let config = PLCrashReporterConfig(signalHandlerType: .BSD, symbolicationStrategy: .symbolTable) 37 | guard let plCrashReporter = PLCrashReporter(configuration: config) else { 38 | Self.log(level: .error, "could not create an instance of PLCrashReporter") 39 | return 40 | } 41 | self.plCrashReporter = plCrashReporter 42 | // enable the PLCrashReporter 43 | do { 44 | try plCrashReporter.enableAndReturnError() 45 | } catch { 46 | Self.log(level: .error, "failed to enable PLCrashReporter: \(error)") 47 | } 48 | } 49 | 50 | func getPendingCrashReport() -> String? { 51 | guard 52 | let plCrashReporter = plCrashReporter, 53 | plCrashReporter.hasPendingCrashReport() 54 | else { 55 | return nil 56 | } 57 | 58 | defer { 59 | // purge the report 60 | plCrashReporter.purgePendingCrashReport() 61 | } 62 | 63 | do { 64 | let data = try plCrashReporter.loadPendingCrashReportDataAndReturnError() 65 | 66 | // retrieve crash reporter data 67 | let report = try PLCrashReport(data: data) 68 | 69 | if let text = PLCrashReportTextFormatter.stringValue(for: report, with: PLCrashReportTextFormatiOS) { 70 | return text 71 | } else { 72 | Self.log(level: .error, "can't convert the report to text") 73 | } 74 | } catch { 75 | Self.log(level: .error, "failed to load and parse crash report: \(error)") 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func forceCrash() { 82 | fatalError("Force crash in \(#function)") 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /Doughnut/Doughnut-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | #ifndef Doughnut_Bridging_Header_h 20 | #define Doughnut_Bridging_Header_h 21 | 22 | #import 23 | 24 | #endif /* Doughnut_Bridging_Header_h */ 25 | -------------------------------------------------------------------------------- /Doughnut/Doughnut-Debug.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.disable-library-validation 6 | 7 | com.apple.security.automation.apple-events 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Doughnut/Doughnut-Release.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.automation.apple-events 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Doughnut/DoughnutApp.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | @objc(DoughnutApp) 22 | class DoughnutApp: NSApplication { 23 | 24 | override init() { 25 | super.init() 26 | } 27 | 28 | required init?(coder: NSCoder) { 29 | fatalError("init(coder:) has not been implemented") 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Doughnut/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | AppIcon 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | 1666453501 23 | DoughnutCrashReportURL 24 | https://github.com/dyerc/Doughnut/issues/new?template=crash_report.md 25 | LSApplicationCategoryType 26 | public.app-category.news 27 | LSMinimumSystemVersion 28 | $(MACOSX_DEPLOYMENT_TARGET) 29 | NSAppTransportSecurity 30 | 31 | NSAllowsArbitraryLoads 32 | 33 | 34 | NSHumanReadableCopyright 35 | Copyright © 2022 Chris Dyer. All rights reserved. 36 | NSMainStoryboardFile 37 | MainMenu 38 | NSPrincipalClass 39 | DoughnutApp 40 | SUFeedURL 41 | https://raw.githubusercontent.com/dyerc/Doughnut/master/appcast.xml 42 | SUPublicDSAKeyFile 43 | dsa_pub.pem 44 | SUPublicEDKey 45 | mbflTORsPYe0weKyTWGD9n135yF5mJWxhrSeHfPuPBE= 46 | 47 | 48 | -------------------------------------------------------------------------------- /Doughnut/Library/MarkupGenerator.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Foundation 20 | 21 | class MarkupGenerator { 22 | 23 | static var styles: String = { 24 | guard 25 | let styleSheetPath = Bundle.main.path(forResource: "detail", ofType: "css"), 26 | let styleSheet = try? String(contentsOf: URL(fileURLWithPath: styleSheetPath), encoding: .utf8) 27 | else { 28 | fatalError("Failed to load the default style sheet.") 29 | } 30 | return styleSheet 31 | }() 32 | 33 | static func template(_ yield: String) -> String { 34 | return """ 35 | 36 | 37 | 38 | 39 | 44 | 45 | 46 | \(yield) 47 | 48 | 49 | """ 50 | } 51 | 52 | static func blankMarkup() -> String { 53 | return template("") 54 | } 55 | 56 | static func markup(forPodcast podcast: Podcast) -> String { 57 | return template(""" 58 |
59 |

\(podcast.description ?? "")

60 | """) 61 | } 62 | 63 | static func markup(forEpisode episode: Episode) -> String { 64 | return template(""" 65 |
66 |

\(episode.description ?? "")

67 | """) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Doughnut/Library/Migrations.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Foundation 20 | import GRDB 21 | 22 | class LibraryMigrations { 23 | static func migrate(db: DatabaseQueue) throws { 24 | var migrator = DatabaseMigrator() 25 | 26 | migrator.registerMigration("v1") { db in 27 | try db.create(table: "podcasts") { t in 28 | t.column("id", .integer).primaryKey() 29 | t.column("title", .text).notNull() 30 | t.column("path", .text).notNull() 31 | t.column("feed", .text) 32 | t.column("description", .text) 33 | t.column("link", .text) 34 | t.column("author", .text) 35 | t.column("language", .text) 36 | t.column("copyright", .text) 37 | t.column("pub_date", .datetime) 38 | t.column("image", .blob) 39 | t.column("image_url", .text) 40 | t.column("last_parsed", .datetime) 41 | t.column("subscribed_at", .datetime) 42 | t.column("download_new", .boolean).notNull().defaults(to: true) 43 | t.column("delete_played", .boolean).notNull().defaults(to: false) 44 | } 45 | 46 | try db.create(table: "episodes", body: { t in 47 | t.column("id", .integer).primaryKey() 48 | t.column("podcast_id", .integer).references("podcasts", onDelete: .cascade) 49 | t.column("title", .text).notNull() 50 | t.column("description", .text) 51 | t.column("guid", .text) 52 | t.column("pub_date", .datetime) 53 | t.column("link", .text) 54 | t.column("enclosure_url", .text) 55 | t.column("enclosure_size", .integer) 56 | t.column("file_name", .text) 57 | t.column("favourite", .boolean).notNull().defaults(to: false) 58 | t.column("downloaded", .boolean).notNull().defaults(to: false) 59 | t.column("played", .boolean).notNull().defaults(to: false) 60 | t.column("play_position", .integer).notNull().defaults(to: 0) 61 | t.column("duration", .integer) 62 | }) 63 | } 64 | 65 | migrator.registerMigration("v2") { db in 66 | try db.alter(table: "podcasts", body: { t in 67 | t.add(column: "reload_frequency", .integer).notNull().defaults(to: 0) 68 | }) 69 | } 70 | 71 | migrator.registerMigration("v3") { db in 72 | try db.alter(table: "podcasts", body: { t in 73 | t.add(column: "auto_download", .boolean).notNull().defaults(to: false) 74 | }) 75 | } 76 | 77 | try migrator.migrate(db) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Doughnut/Library/Storage.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Foundation 20 | 21 | class Storage { 22 | static func librarySize() -> String? { 23 | guard let libraryUrl = Preference.url(for: Preference.Key.libraryPath) else { return nil } 24 | 25 | guard let size = Storage.folderSize(libraryUrl) else { return nil } 26 | 27 | let byteFormatter = ByteCountFormatter() 28 | byteFormatter.allowedUnits = .useGB 29 | byteFormatter.countStyle = .file 30 | return byteFormatter.string(fromByteCount: size) 31 | } 32 | 33 | static func podcastSize(_ podcast: Podcast) -> String? { 34 | guard let url = podcast.storagePath() else { return nil } 35 | 36 | guard let size = Storage.folderSize(url) else { return nil } 37 | 38 | let byteFormatter = ByteCountFormatter() 39 | byteFormatter.allowedUnits = .useMB 40 | byteFormatter.countStyle = .file 41 | 42 | if size > (1024 * 1024 * 1024) { 43 | byteFormatter.allowedUnits = .useGB 44 | } 45 | 46 | return byteFormatter.string(fromByteCount: size) 47 | } 48 | 49 | static func folderSize(_ url: URL) -> Int64? { 50 | var bool: ObjCBool = false 51 | if FileManager.default.fileExists(atPath: url.path, isDirectory: &bool) { 52 | var folderSize = 0 53 | FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey])?.forEach({ 54 | guard let url = $0 as? URL, 55 | let fileSize = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize 56 | else { 57 | return 58 | } 59 | folderSize += fileSize 60 | }) 61 | 62 | return Int64(folderSize) 63 | } 64 | 65 | return nil 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Doughnut/Library/TaskQueue.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Foundation 20 | 21 | protocol TaskProgressDelegate { 22 | func progressed() 23 | } 24 | 25 | class Task: NSObject { 26 | let id = NSUUID().uuidString 27 | let name: String 28 | var detailInformation: String? = "Queued" 29 | 30 | var progressDelegate: TaskProgressDelegate? 31 | 32 | var success: (Any?) -> Void 33 | var failure: (Any?) -> Void 34 | 35 | init(name: String) { 36 | self.name = name 37 | 38 | success = { _ in } 39 | failure = { _ in } 40 | 41 | super.init() 42 | } 43 | 44 | var isIndeterminate: Bool = true 45 | var progressValue: Double = 0 46 | var progressMax: Double = 0 47 | 48 | open func perform(queue: DispatchQueue, completion: @escaping (_ success: Bool, _ object: Any?) -> Void) { 49 | queue.async { 50 | sleep(10) 51 | completion(true, nil) 52 | } 53 | } 54 | 55 | func emitProgress() { 56 | if let delegate = progressDelegate { 57 | DispatchQueue.main.async { 58 | delegate.progressed() 59 | } 60 | } 61 | } 62 | } 63 | 64 | protocol TaskQueueViewDelegate { 65 | func taskPushed(task: Task) 66 | func taskFinished(task: Task) 67 | func tasksRunning(_ running: Bool) 68 | } 69 | 70 | class TaskQueue { 71 | let dispatchQueue = DispatchQueue(label: "com.doughnut.Tasks") 72 | 73 | var tasks = [Task]() 74 | 75 | var delegate: TaskQueueViewDelegate? 76 | 77 | fileprivate(set) var running = false { 78 | didSet { 79 | DispatchQueue.main.async { 80 | self.delegate?.tasksRunning(self.running) 81 | } 82 | } 83 | } 84 | 85 | var count: Int { 86 | return tasks.count 87 | } 88 | 89 | func run(_ task: Task) { 90 | tasks.append(task) 91 | 92 | DispatchQueue.main.async { 93 | self.delegate?.taskPushed(task: task) 94 | } 95 | 96 | if !running { 97 | running = true 98 | 99 | runNextTask() 100 | } 101 | } 102 | 103 | func runNextTask() { 104 | var task: Task? = nil 105 | 106 | if tasks.count > 0 { 107 | task = tasks.remove(at: 0) 108 | } 109 | 110 | if let task = task { 111 | task.perform(queue: dispatchQueue) { (success, object) in 112 | if success { 113 | task.success(object) 114 | } else { 115 | task.failure(object) 116 | } 117 | 118 | DispatchQueue.main.async { 119 | self.delegate?.taskFinished(task: task) 120 | } 121 | 122 | self.runNextTask() 123 | } 124 | } else { 125 | running = false 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Doughnut/Library/Tasks/EpisodeDownloadTask.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import AVFoundation 20 | import Foundation 21 | 22 | class EpisodeDownloadTask: Task, URLSessionDownloadDelegate { 23 | let sessionConfiguration = URLSessionConfiguration.default 24 | let episode: Episode 25 | let podcast: Podcast 26 | 27 | var urlSessionTask: URLSessionDownloadTask? 28 | let byteFormatter = ByteCountFormatter() 29 | 30 | init(episode: Episode, podcast: Podcast) { 31 | self.episode = episode 32 | self.podcast = podcast 33 | 34 | super.init(name: episode.title) 35 | 36 | byteFormatter.allowedUnits = .useMB 37 | byteFormatter.countStyle = .file 38 | 39 | self.success = { object in 40 | let episode = object as! Episode 41 | Library.global.save(episode: episode) 42 | } 43 | 44 | let session = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: nil) 45 | 46 | if let enclosureUrl = episode.enclosureUrl { 47 | if let url = URL(string: enclosureUrl) { 48 | urlSessionTask = session.downloadTask(with: url) 49 | } 50 | } 51 | } 52 | 53 | var complete: ((Bool, Any?) -> Void)? = nil 54 | 55 | override func perform(queue: DispatchQueue, completion: @escaping (Bool, Any?) -> Void) { 56 | if let task = urlSessionTask { 57 | complete = completion 58 | task.resume() 59 | } else { 60 | completion(false, "Invalid episode object") 61 | } 62 | } 63 | 64 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 65 | guard let completion = complete else { return } 66 | 67 | guard let storagePath = podcast.storagePath() else { 68 | completion(false, "Could not determine podcast storage location") 69 | return 70 | } 71 | 72 | let fileName = episode.file() 73 | let outputPath = storagePath.appendingPathComponent(fileName) 74 | 75 | isIndeterminate = true 76 | detailInformation = "Copying to library" 77 | emitProgress() 78 | 79 | do { 80 | try FileManager.default.copyItem(at: location, to: outputPath) 81 | 82 | let avAsset = AVAsset(url: outputPath) 83 | 84 | episode.duration = Int(exactly: avAsset.duration.seconds) ?? 0 85 | episode.downloaded = true 86 | episode.downloading = false 87 | episode.fileName = fileName 88 | 89 | Library.global.save(episode: episode) 90 | 91 | completion(true, episode) 92 | 93 | } catch { 94 | completion(false, "Failed to move downloaded file into position from \(location.path) to \(outputPath.path)") 95 | } 96 | } 97 | 98 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) { 99 | } 100 | 101 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { 102 | progressValue = Double(totalBytesWritten) 103 | progressMax = Double(totalBytesExpectedToWrite) 104 | detailInformation = "\(byteFormatter.string(fromByteCount: Int64(progressValue))) of \(byteFormatter.string(fromByteCount: Int64(progressMax)))" 105 | isIndeterminate = false 106 | 107 | emitProgress() 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Doughnut/Library/Utils.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Foundation 20 | 21 | class Utils { 22 | static func formatDuration(_ seconds: Int) -> String { 23 | guard seconds > 0 else { return "" } 24 | 25 | let formatter = DateComponentsFormatter() 26 | formatter.allowedUnits = [.hour, .minute] 27 | formatter.unitsStyle = .short 28 | 29 | return formatter.string(from: TimeInterval(seconds)) ?? "" 30 | } 31 | 32 | static func iTunesFeedUrl(iTunesUrl: String, completion: @escaping (_ result: String?) -> Void) -> Bool { 33 | guard let iTunesId = Utils.iTunesPodcastId(iTunesUrl: iTunesUrl) else { 34 | return false 35 | } 36 | 37 | guard let iTunesDataUrl = URL(string: "https://itunes.apple.com/lookup?id=\(iTunesId)&entity=podcast") else { 38 | return false 39 | } 40 | 41 | let request = URLSession.shared.dataTask(with: iTunesDataUrl) { data, _, error in 42 | guard let data = data, error == nil else { 43 | completion(nil) 44 | return 45 | } 46 | 47 | do { 48 | let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as! [String: Any] 49 | let results = json["results"] as? [[String: Any]] ?? [] 50 | for r in results { 51 | for result in r { 52 | if result.key == "feedUrl" { 53 | completion((result.value as! String)) 54 | return 55 | } 56 | } 57 | } 58 | } catch let error { 59 | Library.log(level: .error, "Failed to parse iTunes feed with: \(error)") 60 | } 61 | 62 | completion(nil) 63 | } 64 | 65 | request.resume() 66 | return true 67 | } 68 | 69 | static func iTunesPodcastId(iTunesUrl: String) -> String? { 70 | // swiftlint:disable:next force_try 71 | let regex = try! NSRegularExpression(pattern: "\\/id(\\d+)") 72 | let matches = regex.matches(in: iTunesUrl, options: [], range: NSRange(location: 0, length: iTunesUrl.count)) 73 | 74 | for match in matches as [NSTextCheckingResult] { 75 | // range at index 0: full match 76 | // range at index 1: first capture group 77 | let substring = (iTunesUrl as NSString).substring(with: match.range(at: 1)) 78 | return substring 79 | } 80 | 81 | return nil 82 | } 83 | 84 | static func dataToUtf8(_ data: Data) -> Data? { 85 | var convertedString: NSString? 86 | let encoding = NSString.stringEncoding(for: data, encodingOptions: nil, convertedString: &convertedString, usedLossyConversion: nil) 87 | if let str = NSString(data: data, encoding: encoding) as String? { 88 | return str.data(using: .utf8) 89 | } 90 | 91 | return nil 92 | } 93 | 94 | static func removeQueryString(url: URL) -> URL { 95 | let components = NSURLComponents(url: url, resolvingAgainstBaseURL: false) 96 | components?.query = nil 97 | components?.fragment = nil 98 | return components?.url ?? url 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Doughnut/Player/Player+NowPlayingInfoCenter.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Foundation 20 | import MediaPlayer 21 | 22 | extension Player { 23 | 24 | private var nowPlayingPlaybackState: MPNowPlayingPlaybackState { 25 | switch loadStatus { 26 | case .none: 27 | return .stopped 28 | case .loading: 29 | return .paused 30 | case .playing: 31 | if let avPlayer = avPlayer { 32 | return avPlayer.rate == .zero ? .paused : .playing 33 | } else { 34 | return .stopped 35 | } 36 | } 37 | } 38 | 39 | func updateNowPlayingEpisodeInfo() { 40 | Self.log(level: .debug, "[NowPlayingInfo]: updateNowPlayingEpisodeInfo called") 41 | 42 | guard let currentEpisode = currentEpisode else { 43 | nowPlayingEpisodeInfoDictionary = [:] 44 | return 45 | } 46 | 47 | var info: [String: Any] = [ 48 | MPMediaItemPropertyTitle: currentEpisode.title, 49 | MPMediaItemPropertyArtist: currentEpisode.podcast?.author ?? "", 50 | 51 | MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0, 52 | MPNowPlayingInfoPropertyIsLiveStream: false, 53 | MPNowPlayingInfoPropertyExternalContentIdentifier: currentEpisode.guid, 54 | MPNowPlayingInfoPropertyPlaybackProgress: currentEpisode.played ? 0.0 : 1.0, 55 | MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue, 56 | ] 57 | 58 | if let image = currentEpisode.artwork ?? currentEpisode.podcast?.image { 59 | info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size, requestHandler: { _ in image }) 60 | } 61 | 62 | if let url = currentPlaybackURL { 63 | info[MPNowPlayingInfoPropertyAssetURL] = url 64 | } 65 | 66 | nowPlayingEpisodeInfoDictionary = info 67 | } 68 | 69 | func updateNowPlayingPlaybackInfo() { 70 | // Self.logger.debug("[NowPlayingInfo]: updateNowPlayingPlaybackInfo called") 71 | 72 | // TODO: Limit the rate of sending the following values. 73 | let nowPlayingInfoDictionary = nowPlayingEpisodeInfoDictionary.merging([ 74 | MPMediaItemPropertyPlaybackDuration: Double(duration), 75 | MPNowPlayingInfoPropertyElapsedPlaybackTime: position, 76 | MPNowPlayingInfoPropertyPlaybackRate: avPlayer?.rate ?? 1.0, 77 | ]) { $1 } 78 | 79 | MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfoDictionary 80 | 81 | MPNowPlayingInfoCenter.default().playbackState = nowPlayingPlaybackState 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /Doughnut/Player/Player+RemoteCommandCenter.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Foundation 20 | import MediaPlayer 21 | 22 | // See MPRemoteCommandCenter.h for all available commands. 23 | 24 | extension Player { 25 | 26 | func setupRemoteCommands() { 27 | let remoteCommandCenter = MPRemoteCommandCenter.shared() 28 | 29 | // Playback Commands 30 | 31 | remoteCommandCenter.pauseCommand.addTarget { [weak self] _ in 32 | Self.log(level: .debug, "[RemoteCommand]: Receive pauseCommand") 33 | self?.pause() 34 | return .success 35 | } 36 | 37 | remoteCommandCenter.playCommand.addTarget { [weak self] _ in 38 | Self.log(level: .debug, "[RemoteCommand]: Receive playCommand") 39 | self?.play() 40 | return .success 41 | } 42 | 43 | remoteCommandCenter.stopCommand.addTarget { [weak self] _ in 44 | Self.log(level: .debug, "[RemoteCommand]: Receive stopCommand") 45 | self?.stop() 46 | return .success 47 | } 48 | 49 | remoteCommandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in 50 | Self.log(level: .debug, "[RemoteCommand]: Receive togglePlayPauseCommand") 51 | self?.togglePlay() 52 | return .success 53 | } 54 | 55 | remoteCommandCenter.enableLanguageOptionCommand.isEnabled = false 56 | remoteCommandCenter.disableLanguageOptionCommand.isEnabled = false 57 | remoteCommandCenter.changePlaybackRateCommand.isEnabled = false 58 | remoteCommandCenter.changeRepeatModeCommand.isEnabled = false 59 | remoteCommandCenter.changeShuffleModeCommand.isEnabled = false 60 | 61 | // Previous/Next Track Commands 62 | 63 | remoteCommandCenter.nextTrackCommand.isEnabled = false 64 | remoteCommandCenter.previousTrackCommand.isEnabled = false 65 | 66 | // Skip Interval Commands 67 | 68 | remoteCommandCenter.skipForwardCommand.addTarget { [weak self] event in 69 | Self.log(level: .debug, "[RemoteCommand]: Receive skipForwardCommand") 70 | guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed } 71 | self?.skipAhead(seconds: event.interval) 72 | return .success 73 | } 74 | 75 | remoteCommandCenter.skipBackwardCommand.addTarget { [weak self] event in 76 | Self.log(level: .debug, "[RemoteCommand]: Receive skipBackwardCommand") 77 | guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed } 78 | self?.skipBack(seconds: event.interval) 79 | return .success 80 | } 81 | 82 | // Seek Commands 83 | 84 | remoteCommandCenter.seekForwardCommand.isEnabled = false 85 | 86 | remoteCommandCenter.seekBackwardCommand.isEnabled = false 87 | 88 | remoteCommandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in 89 | Self.log(level: .debug, "[RemoteCommand]: Receive changePlaybackPositionCommand") 90 | guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } 91 | self?.seek(seconds: event.positionTime) 92 | return .success 93 | } 94 | 95 | remoteCommandCenter.ratingCommand.isEnabled = false 96 | 97 | // Feedback Commands 98 | // These are generalized to three distinct actions. Your application can provide 99 | // additional context about these actions with the localizedTitle property in 100 | // MPFeedbackCommand. 101 | 102 | remoteCommandCenter.likeCommand.isEnabled = false 103 | remoteCommandCenter.dislikeCommand.isEnabled = false 104 | remoteCommandCenter.bookmarkCommand.isEnabled = false 105 | } 106 | 107 | } 108 | -------------------------------------------------------------------------------- /Doughnut/Preference/PrefAdvancedViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import AppKit 20 | 21 | import MASPreferences 22 | 23 | final class PrefAdvancedViewController: NSViewController, MASPreferencesViewController { 24 | 25 | static func instantiate() -> PrefAdvancedViewController { 26 | let storyboard = NSStoryboard(name: "Preferences", bundle: nil) 27 | return storyboard.instantiateController(withIdentifier: "PrefAdvancedViewController") as! PrefAdvancedViewController 28 | } 29 | 30 | @objc var viewIdentifier: String = "PrefAdvancedViewController" 31 | 32 | @objc var toolbarItemImage: NSImage? { 33 | if #available(macOS 11.0, *) { 34 | return NSImage(systemSymbolName: "gearshape.2", accessibilityDescription: nil)! 35 | } else { 36 | return NSImage(named: NSImage.advancedName) 37 | } 38 | } 39 | 40 | @objc var toolbarItemLabel: String? { 41 | view.layoutSubtreeIfNeeded() 42 | return "Advanced" 43 | } 44 | 45 | @objc var hasResizableWidth: Bool = false 46 | @objc var hasResizableHeight: Bool = false 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Doughnut/Preference/PrefGeneralViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | import MASPreferences 22 | 23 | final class PrefGeneralViewController: NSViewController, MASPreferencesViewController, NSMenuDelegate { 24 | 25 | static func instantiate() -> PrefGeneralViewController { 26 | let storyboard = NSStoryboard(name: "Preferences", bundle: nil) 27 | return storyboard.instantiateController(withIdentifier: "PrefGeneralViewController") as! PrefGeneralViewController 28 | } 29 | 30 | @objc var viewIdentifier: String = "PrefGeneralViewController" 31 | 32 | @objc var toolbarItemImage: NSImage? { 33 | get { 34 | if #available(macOS 11.0, *) { 35 | return NSImage(systemSymbolName: "gearshape", accessibilityDescription: nil)! 36 | } else { 37 | return NSImage(named: NSImage.preferencesGeneralName) 38 | } 39 | } 40 | } 41 | 42 | @objc var toolbarItemLabel: String? { 43 | get { 44 | view.layoutSubtreeIfNeeded() 45 | return "General" 46 | } 47 | } 48 | 49 | override func viewDidLoad() { 50 | super.viewDidLoad() 51 | self.updateAppIconMenuImage() 52 | } 53 | 54 | @objc var hasResizableWidth: Bool = false 55 | @objc var hasResizableHeight: Bool = false 56 | 57 | @IBOutlet weak var appIconPopupButton: NSPopUpButton! 58 | 59 | private func updateAppIconMenuImage() { 60 | /* App Icon Style 61 | ◯ Default 62 | ▢ Square 63 | */ 64 | guard let firstMenuItem = appIconPopupButton.menu?.items.first else { return } 65 | 66 | let isBigSurStyleIconSelected = 67 | Preference.integer(for: Preference.Key.appIconStyle) == Preference.AppIconStyle.bigSur.rawValue 68 | 69 | firstMenuItem.image = isBigSurStyleIconSelected 70 | ? NSImage(named: "PrefAppIcon/Icon_Catalina") 71 | : NSWorkspace.shared.icon(forFile: Bundle.main.bundlePath) 72 | .downSampled(dimension: 16, scale: 2) 73 | } 74 | 75 | // MARK: - NSMenuDelegate 76 | 77 | func menuNeedsUpdate(_ menu: NSMenu) { 78 | if menu == appIconPopupButton.menu { 79 | updateAppIconMenuImage() 80 | } 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /Doughnut/Preference/PrefLibraryViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | import MASPreferences 22 | 23 | final class PrefLibraryViewController: NSViewController, MASPreferencesViewController { 24 | 25 | static func instantiate() -> PrefLibraryViewController { 26 | let storyboard = NSStoryboard(name: "Preferences", bundle: nil) 27 | return storyboard.instantiateController(withIdentifier: "PrefLibraryViewController") as! PrefLibraryViewController 28 | } 29 | 30 | @objc var viewIdentifier: String = "PrefLibraryViewController" 31 | 32 | @objc var toolbarItemImage: NSImage? { 33 | get { 34 | if #available(macOS 11.0, *) { 35 | return NSImage(systemSymbolName: "square.stack", accessibilityDescription: nil)! 36 | } else { 37 | return NSImage(named: "PrefIcon/Library")! 38 | } 39 | } 40 | } 41 | 42 | @objc var toolbarItemLabel: String? { 43 | get { 44 | view.layoutSubtreeIfNeeded() 45 | return "Library" 46 | } 47 | } 48 | 49 | override func viewDidAppear() { 50 | super.viewDidAppear() 51 | 52 | calculateLibrarySize() 53 | } 54 | 55 | @objc var hasResizableWidth: Bool = false 56 | @objc var hasResizableHeight: Bool = false 57 | 58 | @IBOutlet weak var librarySizeTxt: NSTextField! 59 | 60 | func calculateLibrarySize() { 61 | if let librarySize = Storage.librarySize() { 62 | librarySizeTxt.stringValue = librarySize 63 | } 64 | } 65 | 66 | @IBAction func changeLibraryLocation(_ sender: Any) { 67 | let panel = NSOpenPanel() 68 | panel.canChooseDirectories = true 69 | panel.canChooseFiles = false 70 | panel.allowsMultipleSelection = false 71 | 72 | if panel.runModal() == .OK { 73 | if let url = panel.url { 74 | Preference.set(url, for: Preference.Key.libraryPath) 75 | 76 | let alert = NSAlert() 77 | alert.addButton(withTitle: "Ok") 78 | alert.messageText = "Doughnut Will Restart" 79 | alert.informativeText = "Please relaunch Doughnut in order to use the new library location" 80 | 81 | alert.runModal() 82 | exit(0) 83 | } 84 | } 85 | } 86 | 87 | @IBAction func revealLibraryFinder(_ sender: Any) { 88 | NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: Preference.url(for: Preference.Key.libraryPath)?.path ?? "~") 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /Doughnut/Preference/PrefPlaybackViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | import MASPreferences 22 | 23 | final class PrefPlaybackViewController: NSViewController, MASPreferencesViewController { 24 | 25 | static func instantiate() -> PrefPlaybackViewController { 26 | let storyboard = NSStoryboard(name: "Preferences", bundle: nil) 27 | return storyboard.instantiateController(withIdentifier: "PrefPlaybackViewController") as! PrefPlaybackViewController 28 | } 29 | 30 | @objc var viewIdentifier: String = "PrefPlaybackViewController" 31 | 32 | @objc var toolbarItemImage: NSImage? { 33 | get { 34 | if #available(macOS 11.0, *) { 35 | return NSImage(systemSymbolName: "play.circle", accessibilityDescription: nil)! 36 | } else { 37 | return NSImage(named: "PrefIcon/Playback") 38 | } 39 | } 40 | } 41 | 42 | @objc var toolbarItemLabel: String? { 43 | get { 44 | view.layoutSubtreeIfNeeded() 45 | return "Playback" 46 | } 47 | } 48 | 49 | @objc var hasResizableWidth: Bool = false 50 | @objc var hasResizableHeight: Bool = false 51 | 52 | } 53 | -------------------------------------------------------------------------------- /Doughnut/Resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Resources/.gitkeep -------------------------------------------------------------------------------- /Doughnut/Resources/AppIcon_Big_Sur.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/Doughnut/Resources/AppIcon_Big_Sur.icns -------------------------------------------------------------------------------- /Doughnut/Resources/Credits.rtf: -------------------------------------------------------------------------------- 1 | {\rtf1\ansi\ansicpg1252\cocoartf2636 2 | \cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fswiss\fcharset0 Helvetica;} 3 | {\colortbl;\red255\green255\blue255;} 4 | {\*\expandedcolortbl;;} 5 | \paperw11900\paperh16840\margl1440\margr1440\vieww11520\viewh8400\viewkind0 6 | \pard\tx566\tx1133\tx1700\tx2267\tx2834\tx3401\tx3968\tx4535\tx5102\tx5669\tx6236\tx6803\pardirnatural\qc\partightenfactor0 7 | 8 | \f0\fs24 \cf0 Originally created by Chris Dyer\ 9 | \ 10 | Enormous thanks to all contributors:\ 11 | {\field{\*\fldinst{HYPERLINK "https://github.com/GetToSet"}}{\fldrslt Ethan Wong}}\ 12 | {\field{\*\fldinst{HYPERLINK "https://github.com/everplays"}}{\fldrslt everplays}}\ 13 | \pard\pardeftab720\sa280\qc\partightenfactor0 14 | {\field{\*\fldinst{HYPERLINK "https://github.com/lubiedo"}}{\fldrslt \cf0 \expnd0\expndtw0\kerning0 15 | lubiedo}}} -------------------------------------------------------------------------------- /Doughnut/Resources/detail.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | :root { 20 | --text-primary: #777777; 21 | --text-link: #fa469b; 22 | --background-primary: #ffffff; 23 | --border-primary: #d8d8d8; 24 | } 25 | 26 | @media (prefers-color-scheme: dark) { 27 | :root { 28 | --text-primary: #eeeeee; 29 | --text-link: #f558a2; 30 | --background-primary: #484445; 31 | --border-primary: #ababab; 32 | } 33 | } 34 | 35 | html { 36 | font-size: 14px; 37 | } 38 | 39 | body { 40 | font-family: -apple-system, Helvetica, sans-serif; 41 | line-height: 1.5; 42 | background: var(--background-primary); 43 | margin: 0 20px 20px 20px; 44 | color: var(--text-primary); 45 | } 46 | 47 | h1, 48 | h2, 49 | h3, 50 | h4, 51 | h5, 52 | h6 { 53 | margin-top: 0; 54 | margin-bottom: 0.5rem; 55 | font-weight: 500; 56 | } 57 | 58 | p { 59 | margin-top: 0; 60 | margin-bottom: 1rem; 61 | } 62 | 63 | hr { 64 | display: block; 65 | height: 1px; 66 | border: 0; 67 | border-top: 1px solid var(--border-primary); 68 | margin: 0.5rem 0; 69 | padding: 0; 70 | } 71 | 72 | img { 73 | max-width: 100%; 74 | } 75 | 76 | a { 77 | color: var(--text-link); 78 | text-decoration: none; 79 | } 80 | 81 | a:hover { 82 | text-decoration: underline; 83 | cursor: pointer; /* force the hand cursor for links even without href */ 84 | } 85 | 86 | ol, 87 | ul { 88 | padding-left: 2rem; 89 | } 90 | 91 | ol, 92 | ul, 93 | dl { 94 | margin-top: 0; 95 | margin-bottom: 1rem; 96 | } 97 | 98 | ol ol, 99 | ul ul, 100 | ol ul, 101 | ul ol { 102 | margin-bottom: 0; 103 | } 104 | 105 | dd { 106 | margin-bottom: 0.5rem; 107 | margin-left: 0; 108 | } 109 | -------------------------------------------------------------------------------- /Doughnut/Resources/detail.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | // strip styles that may harm readability 20 | function stripStyles() { 21 | // remove style tags 22 | document.body 23 | .querySelectorAll("style, link[rel=stylesheet]") 24 | .forEach((element) => element.remove()); 25 | // strip inline styles 26 | document.body 27 | .querySelectorAll("[style]") 28 | .forEach((element) => element.removeAttribute("style")); 29 | } 30 | 31 | function processDetailPage() { 32 | stripStyles(); 33 | } 34 | -------------------------------------------------------------------------------- /Doughnut/Resources/dsa_pub.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIGRzCCBDoGByqGSM44BAEwggQtAoICAQD7YNOjV3UF3T8oKvQ6tkf50ryStTGf 3 | B41WTbu/0gWObG15OjvFtHEE1F5cnAOL/tsmsucnDyCeOOWGTbUySOVr/i28fSMI 4 | /700d4Sp1J7sDVLzL7ZP7AoTm22FCAjBT/gj96JAVIkU7yU6S2UPpItsO+eJ/RSD 5 | 5vNUc6myk81EcmQiF7ptuH5J7mZgw6Jgw7wTJusjKSGnCihj4SGvLGskfRheyXJU 6 | QZNSEfmqAvk3OzhUZ3u19teeaH70FWQQ+y7+1HQfx6JEVCz1z51NK6K6uwbEOcJB 7 | KqLkIMBS6kgLuRnsvrEjKIvfwTLXpntq/xEKxN3is0KfsiOIwLlnfiE8AjflSIxJ 8 | 6UM0h24aHdIn8eKohMNJQAlgI5OZgiQGwXZJtWURaRRIgf/PxvafUzVkdShjo1ik 9 | OjOi9RtsNszP1w6ReUEI2mPShsyEk7WDs+hW9An8SI47ahQlRnQnyC5NRQPKD/Be 10 | 9nNiKrq7waGRxxqt917mT1onyito7BmWtT8Mtk3IhYCi6YMrJiSZ71VdrTBdBem7 11 | tf8w2JCw6QXVbbdQy4ACNJEbVmVibsrQhQRVqC43xLxzXkyISXxv8n4EHOpZUBFX 12 | KF4KOyoVbG8rszgM4yrOlx7XjKglQwclK4jNRIpzlvCwS+dnCV3waAN24+zWOgSg 13 | /xG9x2maBHETKwIhAIaguWOOKTBp5vC7d/jN9xKKh0Bhkn3lY7VTlYRMUTzLAoIC 14 | AQDprVsQGf6AYxr/CR0nwS14uUWjxw8ZMKghwCfIAP1i+yiQu57EU0daayjXL+Pi 15 | uYWF4ATpfsC+MCGd++Wh/3E5/Lx+c1UYOkp2YsQMe2/z+57/tKD3ew7s/p2kAAEf 16 | DmJsPD2XBSoyN0HzYwf+Mix3/ReqP8ahys+Z5a+QLMWbL2r4lPPrU5LWxKUPou2j 17 | HwHtV4KK6T3rPhwMnBSgcYydx8Hvu21C8Xb0JHSHQFEMzUf+MhQh/fTseWf7L2gc 18 | 5WldZVEK87LRysIdaiSQ5+t7FG68DHIPXc6+LJm01iNPwZiXDOwLlv8zuLBED0w0 19 | NfTXQNnCx033qkqHJBjVLVFJubQNLqWNVgoXGsOJeQlb51KA4hZaJfLaInj27I4K 20 | 93EBhTQT+AXqyEEpJYmtW+XsK2CUzPxzLRQ48c9yWqjBAGB368YY+eOU1IsRXolI 21 | angKRRMskqgTnYXiw4C6iLbbfCw4ITwINBcdBrJu6mWbtz/kIzyx/VyS48iEWFAp 22 | TgFv70B77y6y/mE60P7/idyMzFTiUBJjnnu8HkRWKy/kybe0Ce5NfzDxmB1BrwWj 23 | BXPQMb24sWGMag7UUV8R8uHjXk+LP5Qr2O1KDM4Yd5EvF3sMvGLbdDZ6MTnBWBF2 24 | ufbtCr1NqRBF9ZAc8+qCYRpL42kFaoR0WaWgQqHHg6ol8wOCAgUAAoICAC4Fb/uc 25 | CNGu3Bs0oaWGpEObs+Ce2jMp6stPwNd8gUAKTkbvyvrH0woE4EKSsE4KjSAh9i7m 26 | loKwIegn6/qwXZWchuQqpWQ523kSz8pbxSVfxYsYNYCtgCTyAbdQX+QBlc8A8+Et 27 | ICMaSG3oFZ5WWeUM00Np43Y8QYZ6zU+KIvuWd+1qIae0STfJUTO2b2Jdbvii4+96 28 | 5/3uJ6Ym9uuugSfOP76MLTkpRYxCAyv+ijrCu2excseYhvgC0ZmHb0HoKAs/WACR 29 | O5xCsuZDYVhlOH2PwQGTmHtmsd/+G+KBVIV4+4x5sa0Sr7jQF7sdKWGfWJ1mCqic 30 | QCk/0HFgbaR2zIXxrendWvfUnXWWUW5tu3tSawJd3IjhYLNKLcIUqe/LZuQdtQE5 31 | ui6sTajn79EzdP8p02Av5tf3sygeviMl57hJV9QU/CFqRCczV5X+dacj8cddFmfC 32 | PNZhTd2Pff0BWoANJF/ZY1gIcgKZxjVWiBv4VoPwPI9eWg9FFFmpciJRlpgc8vOv 33 | wyne4QoqOCTL727XUi2hj5cdsBPXcZtGIhYDM6imhU6VizTBfvvaEpUieZSH+dBD 34 | sA2OMTiEtqmCODHqJijiQB0UFUgoqSuT8iQ7QdYk8e7cVZspZmIfXJe8hJJBhTDg 35 | oPMNIpL+qzHr8/iIn4wEL7HosIyhDcZA/3cH 36 | -----END PUBLIC KEY----- 37 | -------------------------------------------------------------------------------- /Doughnut/Utilities/CMTime+Extensions.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import CoreMedia 20 | import Foundation 21 | 22 | extension CMTime { 23 | 24 | init(seconds: Double) { 25 | self.init(seconds: seconds, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Doughnut/Utilities/ModalSheetSegue.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import AppKit 20 | 21 | final class ModalSheetStoryboardSegue: NSStoryboardSegue { 22 | 23 | override func perform() { 24 | let resolveViewController: (Any) -> NSViewController? = { controller in 25 | if let controller = controller as? NSViewController { 26 | return controller 27 | } else if let controller = controller as? NSWindowController { 28 | return controller.contentViewController 29 | } 30 | return nil 31 | } 32 | 33 | guard 34 | let sourceViewController = resolveViewController(sourceController), 35 | let destinationViewController = resolveViewController(destinationController) 36 | else { 37 | assert(false, "ModalSheetStoryboardSegue: failed to resolve viewControllers in \(#function)") 38 | return 39 | } 40 | sourceViewController.presentAsSheet(destinationViewController) 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Doughnut/Utilities/NSAppearance+Extensions.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import AppKit 20 | 21 | extension NSAppearance { 22 | 23 | var isDarkMode: Bool { 24 | return bestMatch(from: [.darkAqua, .aqua]) == .darkAqua 25 | } 26 | 27 | // https://stackoverflow.com/questions/52504872/updating-for-dark-mode-nscolor-ignores-appearance-changes 28 | static func withAppAppearance(_ closure: () throws -> T) rethrows -> T { 29 | let previousAppearance = NSAppearance.current 30 | NSAppearance.current = NSApp.effectiveAppearance 31 | defer { 32 | NSAppearance.current = previousAppearance 33 | } 34 | return try closure() 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Doughnut/Utilities/NSButton+Extensions.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import AppKit 20 | 21 | extension NSButton { 22 | 23 | func setTitleColor(_ color: NSColor) { 24 | let coloredTitle = NSMutableAttributedString( 25 | string: title, 26 | attributes: [ 27 | .foregroundColor: color, 28 | ] 29 | ) 30 | attributedTitle = coloredTitle 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /Doughnut/Utilities/NSImage+Extensions.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import AppKit 20 | 21 | extension NSImage { 22 | 23 | static func downSampledImage(withData data: Data, dimension: CGFloat, scale: CGFloat) -> NSImage? { 24 | let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary 25 | guard let imageSource = CGImageSourceCreateWithData(data as CFData, imageSourceOptions) else { 26 | return nil 27 | } 28 | 29 | let dimensionInPixels = dimension * scale 30 | let downsampleOptions = [ 31 | kCGImageSourceCreateThumbnailFromImageAlways: true, 32 | kCGImageSourceShouldCacheImmediately: true, 33 | kCGImageSourceCreateThumbnailWithTransform: true, 34 | kCGImageSourceThumbnailMaxPixelSize: dimensionInPixels, 35 | ] as CFDictionary 36 | guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else { 37 | return nil 38 | } 39 | 40 | let imageSize = CGSize( 41 | width: CGFloat(downsampledImage.width) / scale, 42 | height: CGFloat(downsampledImage.height) / scale 43 | ) 44 | return NSImage(cgImage: downsampledImage, size: imageSize) 45 | } 46 | 47 | func downSampled(dimension: CGFloat, scale: CGFloat) -> NSImage? { 48 | guard let data = tiffRepresentation else { return nil } 49 | return Self.downSampledImage(withData: data, dimension: dimension, scale: scale) 50 | } 51 | 52 | func jpegRepresentation(withCompressionFactor compressionFactor: CGFloat = 1.0) -> Data? { 53 | guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { 54 | return nil 55 | } 56 | let bitmapRep = NSBitmapImageRep(cgImage: cgImage) 57 | return bitmapRep.representation(using: .jpeg, properties: [:]) 58 | } 59 | 60 | // https://gist.github.com/usagimaru/c0a03ef86b5829fb9976b650ec2f1bf4 61 | func tinted(with tintColor: NSColor) -> NSImage { 62 | if isTemplate == false { 63 | return self 64 | } 65 | 66 | let image = copy() as! NSImage 67 | image.lockFocus() 68 | 69 | tintColor.set() 70 | 71 | let imageRect = NSRect(origin: .zero, size: image.size) 72 | imageRect.fill(using: .sourceIn) 73 | 74 | image.unlockFocus() 75 | image.isTemplate = false 76 | 77 | return image 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /Doughnut/Utilities/NSMenu+Extensions.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import AppKit 20 | 21 | extension NSMenu { 22 | 23 | enum MenuType { 24 | case main 25 | case dock 26 | case contextual 27 | } 28 | 29 | var menuType: MenuType { 30 | let topMenu = topMenu 31 | if topMenu == NSApp.mainMenu { 32 | return .main 33 | } else if topMenu == NSApp.delegate?.applicationDockMenu?(NSApp) { 34 | return .dock 35 | } else { 36 | return .contextual 37 | } 38 | } 39 | 40 | var topMenu: NSMenu { 41 | var current: NSMenu? = self 42 | while current?.supermenu != nil { 43 | current = current?.supermenu 44 | } 45 | return current! 46 | } 47 | 48 | var menuItem: NSMenuItem? { 49 | return supermenu?.items.first { 50 | $0.submenu == self 51 | } 52 | } 53 | 54 | } 55 | 56 | extension NSMenuItem { 57 | 58 | var topMenu: NSMenu? { 59 | return menu?.topMenu 60 | } 61 | 62 | var menuType: NSMenu.MenuType? { 63 | return menu?.menuType 64 | } 65 | 66 | func configureWithDefaultFont() { 67 | configureWithSystemFont(ofSize: NSFont.systemFontSize) 68 | } 69 | 70 | func configureWithSystemFont(ofSize size: CGFloat) { 71 | attributedTitle = NSAttributedString( 72 | string: title, 73 | attributes: [ 74 | .font: NSFont.controlContentFont(ofSize: size), 75 | ] 76 | ) 77 | } 78 | 79 | } 80 | -------------------------------------------------------------------------------- /Doughnut/Utilities/NSTableView+Extensions.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import AppKit 20 | 21 | extension NSTableView { 22 | 23 | var activeRowIndices: IndexSet { 24 | if clickedRow != -1, !selectedRowIndexes.contains(clickedRow) { 25 | return [clickedRow] 26 | } 27 | return selectedRowIndexes 28 | } 29 | 30 | var availableRowIndices: IndexSet { 31 | var avaliableIndices = IndexSet() 32 | enumerateAvailableRowViews { _, index in 33 | avaliableIndices.insert(index) 34 | } 35 | return avaliableIndices 36 | } 37 | 38 | var availableRowIndicesRange: Range { 39 | let availableRowIndices = availableRowIndices 40 | if !availableRowIndices.isEmpty { 41 | return Range((availableRowIndices.min()!)...(availableRowIndices.max()!)) 42 | } 43 | return 0..<0 44 | } 45 | 46 | func reloadData(forRowIndexes rowIndexes: IndexSet) { 47 | reloadData(forRowIndexes: rowIndexes, columnIndexes: IndexSet(0... 17 | */ 18 | 19 | import AppKit 20 | 21 | extension NSView { 22 | 23 | var compatibleSafeAreaLayoutGuide: Any { 24 | if #available(macOS 11.0, *) { 25 | return safeAreaLayoutGuide 26 | } 27 | return self 28 | } 29 | 30 | func popUpContextualMenu(_ menu: NSMenu) { 31 | guard let event = NSApp.currentEvent else { 32 | return 33 | } 34 | NSMenu.popUpContextMenu(menu, with: event, for: self) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Doughnut/Utilities/OSLog+Extensions.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Foundation 20 | import OSLog 21 | 22 | extension OSLog { 23 | 24 | static func main(category: String) -> OSLog { 25 | return OSLog(subsystem: Bundle.main.bundleIdentifier!, category: category) 26 | } 27 | 28 | // This is a compromised approach since `os.Logger` and `os.OSLogMessage` 29 | // requires macOS 11.0. 30 | // See also: https://stackoverflow.com/questions/53025698#62488271 31 | // TODO: Migrate to `os.Logger` when we drop the support for 10.15 (Catalina). 32 | func callAsFunction(level: OSLogType, _ s: String) { 33 | os_log(level, log: self, "%{public}s", s) 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /Doughnut/View Controllers/DetailViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | import WebKit 21 | 22 | enum DetailViewType { 23 | case BlankDetail 24 | case PodcastDetail 25 | case EpisodeDetail 26 | } 27 | 28 | final class DetailViewController: NSViewController, WKNavigationDelegate { 29 | 30 | @IBOutlet weak var detailTitle: NSTextField! 31 | @IBOutlet weak var secondaryTitle: NSTextField! 32 | @IBOutlet weak var miniTitle: NSTextField! 33 | @IBOutlet weak var coverImage: NSImageView! 34 | 35 | @IBOutlet weak var headerView: NSView! 36 | @IBOutlet weak var webView: WKWebView! 37 | 38 | let dateFormatter = DateFormatter() 39 | 40 | var detailType: DetailViewType = .BlankDetail { 41 | didSet { 42 | switch detailType { 43 | case .PodcastDetail: 44 | showPodcast() 45 | 46 | case .EpisodeDetail: 47 | showEpisode() 48 | 49 | default: 50 | showBlank() 51 | } 52 | } 53 | } 54 | 55 | var episode: Episode? { 56 | didSet { 57 | if episode != nil { 58 | detailType = .EpisodeDetail 59 | } else if podcast != nil { 60 | detailType = .PodcastDetail 61 | } else { 62 | detailType = .BlankDetail 63 | } 64 | } 65 | } 66 | 67 | var podcast: Podcast? { 68 | didSet { 69 | if podcast != nil { 70 | if podcast?.id != oldValue?.id { 71 | detailType = .PodcastDetail 72 | } 73 | } else { 74 | detailType = .BlankDetail 75 | } 76 | } 77 | } 78 | 79 | override func viewDidLoad() { 80 | super.viewDidLoad() 81 | 82 | dateFormatter.dateStyle = .long 83 | view.wantsLayer = true 84 | 85 | NSLayoutConstraint( 86 | item: headerView!, 87 | attribute: .top, 88 | relatedBy: .equal, 89 | toItem: view.compatibleSafeAreaLayoutGuide, 90 | attribute: .top, 91 | multiplier: 1, 92 | constant: 16 93 | ).isActive = true 94 | 95 | showBlank() 96 | 97 | if Preference.bool(for: Preference.Key.debugDeveloperExtrasEnabled) { 98 | webView.configuration.preferences.setValue(true, forKey: "developerExtrasEnabled") 99 | } 100 | webView.configuration.preferences.javaScriptEnabled = true 101 | webView.navigationDelegate = self 102 | } 103 | 104 | func showBlank() { 105 | detailTitle.stringValue = "" 106 | secondaryTitle.stringValue = "" 107 | miniTitle.stringValue = "" 108 | coverImage.image = nil 109 | 110 | webView.loadHTMLString(MarkupGenerator.blankMarkup(), baseURL: Bundle.main.resourceURL) 111 | } 112 | 113 | func showPodcast() { 114 | guard let podcast = podcast else { 115 | showBlank() 116 | return 117 | } 118 | 119 | detailTitle.stringValue = podcast.title 120 | secondaryTitle.stringValue = podcast.author ?? "" 121 | miniTitle.stringValue = podcast.link ?? "" 122 | coverImage.image = podcast.image 123 | 124 | webView.loadHTMLString(MarkupGenerator.markup(forPodcast: podcast), baseURL: Bundle.main.resourceURL) 125 | } 126 | 127 | func showEpisode() { 128 | guard let episode = episode else { 129 | showBlank() 130 | return 131 | } 132 | 133 | detailTitle.stringValue = episode.title 134 | secondaryTitle.stringValue = podcast?.title ?? "" 135 | 136 | if let pubDate = episode.pubDate { 137 | miniTitle.stringValue = dateFormatter.string(for: pubDate) ?? "" 138 | } 139 | 140 | if let artwork = episode.artwork { 141 | coverImage.image = artwork 142 | } else { 143 | coverImage.image = podcast?.image 144 | } 145 | 146 | webView.loadHTMLString(MarkupGenerator.markup(forEpisode: episode), baseURL: Bundle.main.resourceURL) 147 | } 148 | 149 | func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { 150 | if navigationAction.navigationType == .linkActivated { 151 | if let url = navigationAction.request.url { 152 | NSWorkspace.shared.open(url) 153 | } 154 | 155 | decisionHandler(.cancel) 156 | } else { 157 | decisionHandler(.allow) 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /Doughnut/View Controllers/EpisodeFilterViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | class EpisodeFilterViewController: NSViewController, NSTableViewDelegate, NSTableViewDataSource { 22 | 23 | } 24 | -------------------------------------------------------------------------------- /Doughnut/View Controllers/SubscribeViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | class SubscribeViewController: NSViewController, NSTextFieldDelegate { 22 | let reducedHeight: CGFloat = 120.0 23 | var initialHeight: CGFloat = 0 24 | 25 | @IBOutlet weak var urlTxt: NSTextField! 26 | @IBOutlet weak var loadingIndicator: NSProgressIndicator! 27 | 28 | @IBOutlet weak var imageView: NSImageView! 29 | @IBOutlet weak var feedTitleTxt: NSTextField! 30 | @IBOutlet weak var feedDescriptionTxt: NSTextField! 31 | 32 | @IBOutlet weak var loadBtn: NSButton! 33 | @IBOutlet weak var cancelBtn: NSButton! 34 | @IBOutlet weak var subscribeBtn: NSButton! 35 | 36 | var detectedPodcast: Podcast? 37 | 38 | override func viewDidLoad() { 39 | initialHeight = view.frame.height 40 | 41 | preferredContentSize = CGSize(width: view.frame.size.width, height: reducedHeight) 42 | 43 | loadingIndicator.stopAnimation(self) 44 | 45 | imageView.isHidden = true 46 | feedTitleTxt.isHidden = true 47 | feedDescriptionTxt.isHidden = true 48 | subscribeBtn.isHidden = true 49 | 50 | // Check pasteboard for feed 51 | if let pastedUrl = NSPasteboard.general.string(forType: .string) { 52 | if pastedUrl.starts(with: "http") { 53 | urlTxt.stringValue = pastedUrl 54 | loadFeed(self) 55 | } 56 | } 57 | } 58 | 59 | @IBAction func loadFeed(_ sender: Any) { 60 | let loading = Podcast.detect(url: urlTxt.stringValue) { podcast in 61 | self.loadBtn.isEnabled = true 62 | self.loadingIndicator.stopAnimation(self) 63 | 64 | if let podcast = podcast { 65 | self.subscribeBtn.isEnabled = true 66 | self.imageView.image = podcast.image 67 | self.feedTitleTxt.stringValue = podcast.title 68 | self.feedDescriptionTxt.stringValue = podcast.description ?? "" 69 | 70 | self.detectedPodcast = podcast 71 | self.expand() 72 | } else { 73 | let alert = NSAlert() 74 | alert.messageText = "Unable to Detect Feed URL" 75 | alert.runModal() 76 | } 77 | } 78 | 79 | if loading { 80 | loadBtn.isEnabled = false 81 | loadingIndicator.startAnimation(self) 82 | } 83 | } 84 | 85 | @IBAction func subscribe(_ sender: Any) { 86 | guard let detectedPodcast = detectedPodcast else { return } 87 | Library.global.subscribe(podcast: detectedPodcast) 88 | 89 | dismiss(self) 90 | } 91 | 92 | func expand() { 93 | cancelBtn.isHidden = true 94 | 95 | imageView.isHidden = false 96 | feedTitleTxt.isHidden = false 97 | feedDescriptionTxt.isHidden = false 98 | subscribeBtn.isHidden = false 99 | 100 | preferredContentSize = CGSize(width: view.frame.size.width, height: initialHeight) 101 | } 102 | 103 | func controlTextDidChange(_ obj: Notification) { 104 | if urlTxt.stringValue.starts(with: "http") && urlTxt.stringValue.contains(".") { 105 | loadBtn.isEnabled = true 106 | } else { 107 | loadBtn.isEnabled = false 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Doughnut/View Controllers/TasksViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | let TASK_VIEW_HEIGHT: CGFloat = 55 22 | 23 | class TaskView: NSView, TaskProgressDelegate { 24 | let titleLabelView: NSTextField 25 | let progressView: NSProgressIndicator 26 | let informationLabelView: NSTextField 27 | 28 | let task: Task 29 | 30 | init(task: Task, frame frameRect: NSRect) { 31 | self.task = task 32 | 33 | titleLabelView = NSTextField(frame: NSRect(x: 0, y: 38, width: frameRect.width, height: 17)) 34 | titleLabelView.stringValue = task.name 35 | titleLabelView.isBezeled = false 36 | titleLabelView.drawsBackground = false 37 | titleLabelView.isSelectable = false 38 | titleLabelView.font = NSFont.systemFont(ofSize: 12) 39 | titleLabelView.isEditable = false 40 | 41 | progressView = NSProgressIndicator(frame: NSRect(x: 0, y: 18, width: frameRect.width, height: 20)) 42 | progressView.minValue = 0 43 | progressView.maxValue = 0 44 | progressView.doubleValue = 0 45 | progressView.isIndeterminate = true 46 | progressView.style = .bar 47 | 48 | informationLabelView = NSTextField(frame: NSRect(x: 0, y: 4, width: frameRect.width, height: 14)) 49 | informationLabelView.stringValue = task.detailInformation ?? "" 50 | informationLabelView.isBezeled = false 51 | informationLabelView.drawsBackground = false 52 | informationLabelView.isSelectable = false 53 | informationLabelView.font = NSFont.systemFont(ofSize: 10) 54 | informationLabelView.textColor = NSColor.gray 55 | informationLabelView.isEditable = false 56 | 57 | super.init(frame: frameRect) 58 | 59 | addSubview(titleLabelView) 60 | addSubview(progressView) 61 | addSubview(informationLabelView) 62 | progressView.startAnimation(self) 63 | 64 | task.progressDelegate = self 65 | } 66 | 67 | required convenience init?(coder decoder: NSCoder) { 68 | self.init(task: Task(name: ""), frame: NSRect()) 69 | } 70 | 71 | override var intrinsicContentSize: NSSize { 72 | get { 73 | return NSSize(width: bounds.size.width, height: TASK_VIEW_HEIGHT) 74 | } 75 | } 76 | 77 | func progressed() { 78 | progressView.isIndeterminate = task.isIndeterminate 79 | progressView.doubleValue = task.progressValue 80 | progressView.maxValue = task.progressMax 81 | informationLabelView.stringValue = task.detailInformation ?? "" 82 | } 83 | } 84 | 85 | class TasksViewController: NSViewController, TaskQueueViewDelegate { 86 | @IBOutlet weak var stackView: NSStackView! 87 | 88 | override func viewDidLoad() { 89 | super.viewDidLoad() 90 | 91 | stackView.translatesAutoresizingMaskIntoConstraints = false 92 | } 93 | 94 | func taskPushed(task: Task) { 95 | let view = TaskView(task: task, frame: NSRect(x: 0, y: 0, width: stackView.bounds.width, height: TASK_VIEW_HEIGHT)) 96 | stackView.addView(view, in: .top) 97 | } 98 | 99 | func taskFinished(task: Task) { 100 | let taskView = stackView.views.first { view -> Bool in 101 | return (view as! TaskView).task == task 102 | } 103 | 104 | if let matchedView = taskView { 105 | stackView.removeView(matchedView) 106 | matchedView.removeFromSuperview() 107 | } 108 | } 109 | 110 | func tasksRunning(_ running: Bool) { 111 | 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Doughnut/View Controllers/ViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | final class ViewController: NSSplitViewController, LibraryDelegate { 22 | 23 | static let minimumWidthToShowWindowTitle: CGFloat = 930 24 | 25 | enum Events: String { 26 | case PodcastSelected 27 | 28 | var notification: Notification.Name { 29 | return Notification.Name(rawValue: self.rawValue) 30 | } 31 | } 32 | 33 | var podcastViewController: PodcastViewController { 34 | get { 35 | return splitViewItems[0].viewController as! PodcastViewController 36 | } 37 | } 38 | 39 | var episodeViewController: EpisodeViewController { 40 | get { 41 | return splitViewItems[1].viewController as! EpisodeViewController 42 | } 43 | } 44 | 45 | var detailViewController: DetailViewController { 46 | get { 47 | return splitViewItems[2].viewController as! DetailViewController 48 | } 49 | } 50 | 51 | override func viewDidLoad() { 52 | super.viewDidLoad() 53 | 54 | UserDefaults.standard.addObserver(self, forKeyPath: Preference.Key.showDockBadge.rawValue, options: [], context: nil) 55 | 56 | splitView.autosaveName = "Main" 57 | 58 | Library.global.delegate = self 59 | } 60 | 61 | override func viewWillAppear() { 62 | super.viewWillAppear() 63 | 64 | updateWindowTitleAndDockIcon() 65 | } 66 | 67 | deinit { 68 | UserDefaults.standard.removeObserver(self, forKeyPath: Preference.Key.showDockBadge.rawValue) 69 | } 70 | 71 | override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { 72 | switch keyPath { 73 | case Preference.Key.showDockBadge.rawValue?: 74 | updateWindowTitleAndDockIcon() 75 | default: 76 | return 77 | } 78 | } 79 | 80 | func selectPodcast(podcast: Podcast?) { 81 | episodeViewController.selectPodcast(podcast) 82 | detailViewController.podcast = podcast 83 | updateWindowTitle() 84 | } 85 | 86 | func selectEpisode(episode: Episode?) { 87 | detailViewController.episode = episode 88 | } 89 | 90 | // MARK: Library Delegate 91 | func libraryReloaded() { 92 | podcastViewController.reloadPodcasts() 93 | updateWindowTitleAndDockIcon() 94 | } 95 | 96 | func librarySubscribedToPodcast(subscribed: Podcast) { 97 | podcastViewController.reloadPodcasts() 98 | updateWindowTitleAndDockIcon() 99 | } 100 | 101 | func libraryUnsubscribedFromPodcast(unsubscribed: Podcast) { 102 | podcastViewController.reloadPodcasts() 103 | 104 | if episodeViewController.podcast?.id == unsubscribed.id { 105 | selectPodcast(podcast: nil) 106 | } 107 | 108 | updateWindowTitleAndDockIcon() 109 | } 110 | 111 | func libraryUpdatingPodcasts(podcasts: [Podcast]) { 112 | podcastViewController.reload(forChangedPodcasts: podcasts) 113 | updateWindowTitleAndDockIcon() 114 | } 115 | 116 | func libraryUpdatedPodcasts(podcasts: [Podcast]) { 117 | podcastViewController.reload(forChangedPodcasts: podcasts) 118 | 119 | if podcasts.contains(where: { episodeViewController.podcast?.id == $0.id }) { 120 | episodeViewController.reloadEpisodes() 121 | } 122 | 123 | updateWindowTitleAndDockIcon() 124 | } 125 | 126 | func libraryUpdatedEpisodes(episodes: [Episode]) { 127 | let currentEpisodes = episodes.filter { 128 | episodeViewController.podcast?.id == $0.podcastId 129 | } 130 | 131 | episodeViewController.reload(forChangedEpisodes: currentEpisodes) 132 | 133 | var podcasts = [Podcast]() 134 | 135 | for episode in episodes { 136 | if let podcast = episode.podcast, !podcasts.contains(where: { $0 === podcast }) { 137 | podcasts.append(podcast) 138 | } 139 | } 140 | 141 | podcastViewController.reload(forChangedPodcasts: podcasts) 142 | 143 | updateWindowTitleAndDockIcon() 144 | } 145 | 146 | // MARK: Actions 147 | 148 | @IBAction func toggleFilterEpisodes(_ sender: Any) { 149 | // sender 150 | episodeViewController.toggleFilter() 151 | } 152 | 153 | func updateWindowTitleVisibility() { 154 | if #available(macOS 11.0, *) { 155 | let primaryColumnsWidth = episodeViewController.view.bounds.width + detailViewController.view.bounds.width 156 | view.window?.titleVisibility = primaryColumnsWidth >= Self.minimumWidthToShowWindowTitle 157 | ? .visible : .hidden 158 | } 159 | } 160 | 161 | private func updateWindowTitle() { 162 | if #available(macOS 11.0, *) { 163 | if let podcast = detailViewController.podcast { 164 | view.window?.title = podcast.title 165 | view.window?.subtitle = "\(podcast.unplayedCount) Unplayed" 166 | } else { 167 | view.window?.title = "Doughnut" 168 | view.window?.subtitle = "" 169 | } 170 | } 171 | } 172 | 173 | private func updateWindowTitleAndDockIcon() { 174 | updateWindowTitle() 175 | updateDockIcon() 176 | } 177 | 178 | private func updateDockIcon() { 179 | if Preference.bool(for: Preference.Key.showDockBadge) { 180 | let unplayedCount = Library.global.unplayedCount 181 | 182 | if unplayedCount > 0 { 183 | NSApplication.shared.dockTile.badgeLabel = String(unplayedCount) 184 | } else { 185 | NSApplication.shared.dockTile.badgeLabel = nil 186 | } 187 | } else { 188 | NSApplication.shared.dockTile.badgeLabel = nil 189 | } 190 | } 191 | 192 | func search(_ query: String?) { 193 | episodeViewController.searchQuery = query 194 | } 195 | 196 | } 197 | 198 | extension ViewController { 199 | 200 | override func splitViewDidResizeSubviews(_ notification: Notification) { 201 | updateWindowTitleVisibility() 202 | } 203 | 204 | } 205 | -------------------------------------------------------------------------------- /Doughnut/Views/ActivityIndicator.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | class ActivityIndicator: NSView { 22 | let dotSize: CGFloat = 6.0 23 | let dotSpacing: CGFloat = 3.0 24 | let dotCount = 3 25 | 26 | override func viewDidMoveToWindow() { 27 | wantsLayer = true 28 | 29 | let replLayer = CAReplicatorLayer() 30 | replLayer.frame = bounds 31 | 32 | let dotsX = (bounds.width - (dotSize * 3) - (dotSize * 2)) / 2 33 | 34 | let dot = CALayer() 35 | dot.frame = CGRect(x: dotsX, y: (bounds.height - dotSize) / 2, width: dotSize, height: dotSize) 36 | dot.backgroundColor = NSColor.darkGray.cgColor 37 | dot.cornerRadius = dotSize / 2 38 | 39 | replLayer.addSublayer(dot) 40 | replLayer.instanceCount = dotCount 41 | replLayer.instanceTransform = CATransform3DMakeTranslation(dotSize + dotSpacing, 0, 0) 42 | 43 | let animation = CAKeyframeAnimation() 44 | animation.keyPath = #keyPath(CALayer.opacity) 45 | animation.values = [0.0, 1.0, 0.0] 46 | animation.duration = 1.2 47 | animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) 48 | animation.repeatCount = .infinity 49 | dot.add(animation, forKey: nil) 50 | 51 | replLayer.instanceDelay = 0.2 52 | 53 | layer?.frame = self.frame 54 | layer?.addSublayer(replLayer) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Doughnut/Views/BackgroundView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | final class BackgroundView: NSView { 22 | 23 | var backagroundColor = NSColor(named: "ViewBackground")! { 24 | didSet { 25 | needsDisplay = true 26 | } 27 | } 28 | 29 | var isMovableByViewBackground: Bool? 30 | 31 | override init(frame frameRect: NSRect) { 32 | super.init(frame: frameRect) 33 | commonInit() 34 | } 35 | 36 | required init?(coder: NSCoder) { 37 | super.init(coder: coder) 38 | commonInit() 39 | } 40 | 41 | private func commonInit() { 42 | wantsLayer = true 43 | } 44 | 45 | override var wantsUpdateLayer: Bool { 46 | return true 47 | } 48 | 49 | override func updateLayer() { 50 | layer?.backgroundColor = backagroundColor.cgColor 51 | } 52 | 53 | override var mouseDownCanMoveWindow: Bool { 54 | return isMovableByViewBackground ?? super.mouseDownCanMoveWindow 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /Doughnut/Views/BaseTableView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import AppKit 20 | 21 | final class BaseTableView: NSTableView { 22 | 23 | override func responds(to selector: Selector!) -> Bool { 24 | // NSWindow converts certain keys that control UI into actions. 25 | // For the 'Space' menu key equivalent to work properly, we need to prevent 26 | // 'performClick:' from being called, so that the event has a chance to 27 | // propagate to the main menu. 28 | // 29 | // See https://stackoverflow.com/questions/11155239/nsmenuitem-keyequivalent-space-bug#54006299 30 | // for detailed explanations. 31 | if selector == #selector(performClick(_:)) { return false } 32 | return super.responds(to: selector) 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /Doughnut/Views/DetailWebView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | import WebKit 21 | 22 | class DetailWebView: WKWebView { 23 | } 24 | -------------------------------------------------------------------------------- /Doughnut/Views/PodcastCellView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | class PodcastUnplayedCountView: NSView { 22 | var value = 0 { 23 | didSet { 24 | updateState() 25 | } 26 | } 27 | 28 | var loading: Bool = false { 29 | didSet { 30 | updateState() 31 | } 32 | } 33 | 34 | // Render in blue on white bg 35 | var highlightColor = false { 36 | didSet { 37 | self.needsDisplay = true 38 | } 39 | } 40 | 41 | let loadingIndicator = NSProgressIndicator() 42 | 43 | required init?(coder decoder: NSCoder) { 44 | super.init(coder: decoder) 45 | 46 | loadingIndicator.isHidden = true 47 | loadingIndicator.style = .spinning 48 | loadingIndicator.isIndeterminate = true 49 | 50 | addSubview(loadingIndicator) 51 | } 52 | 53 | func updateState() { 54 | if loading { 55 | isHidden = false 56 | loadingIndicator.isHidden = false 57 | loadingIndicator.startAnimation(self) 58 | } else if (value >= 1) { 59 | isHidden = false 60 | loadingIndicator.isHidden = true 61 | loadingIndicator.stopAnimation(self) 62 | 63 | // 64 | let paragraphStyle = NSMutableParagraphStyle() 65 | paragraphStyle.paragraphSpacing = 0 66 | paragraphStyle.lineSpacing = 0 67 | 68 | attrString = NSMutableAttributedString(string: String(value), attributes: [ 69 | .font: NSFont.boldSystemFont(ofSize: 11), 70 | .foregroundColor: NSColor.white, 71 | .paragraphStyle: paragraphStyle, 72 | ]) 73 | } else { 74 | isHidden = true 75 | } 76 | } 77 | 78 | override func viewDidMoveToWindow() { 79 | loadingIndicator.frame = NSRect(x: frame.width - 16 - 3, y: (frame.height - 16) / 2, width: 16.0, height: 16.0) 80 | } 81 | 82 | var attrString = NSMutableAttributedString(string: "") 83 | 84 | override func draw(_ dirtyRect: NSRect) { 85 | if !loading { 86 | let bb = attrString.boundingRect(with: CGSize(width: 50, height: 18), options: []) 87 | 88 | let X_PAD: CGFloat = 7.0 89 | let Y_PAD: CGFloat = 2.0 90 | 91 | let bgWidth = bb.width + (X_PAD * CGFloat(2)) 92 | let bgHeight = bb.height + (Y_PAD * CGFloat(2)) 93 | let bgMidPoint: CGFloat = bgHeight * 0.5 94 | let bgRect = NSRect(x: bounds.width - bgWidth, y: bounds.midY - bgMidPoint, width: bgWidth, height: bgHeight) 95 | 96 | let bg = NSBezierPath(roundedRect: bgRect, xRadius: 5, yRadius: 5) 97 | 98 | if highlightColor { 99 | NSColor.black.withAlphaComponent(0.15).setFill() 100 | } else { 101 | NSColor.gray.setFill() 102 | } 103 | 104 | bg.fill() 105 | 106 | attrString.draw(with: NSRect(x: bgRect.minX + X_PAD, y: bgRect.minY + 3 + Y_PAD, width: bb.width, height: bb.height), options: []) 107 | } 108 | } 109 | } 110 | 111 | class PodcastCellView: NSTableCellView { 112 | 113 | @IBOutlet weak var artwork: NSImageView! 114 | @IBOutlet weak var title: NSTextField! 115 | @IBOutlet weak var author: NSTextField! 116 | @IBOutlet weak var episodeCount: NSTextField! 117 | @IBOutlet weak var podcastUnplayedCount: PodcastUnplayedCountView! 118 | @IBOutlet weak var podcastUnplayedCountTrailingConstraint: NSLayoutConstraint! 119 | 120 | override func viewWillMove(toWindow newWindow: NSWindow?) { 121 | super.viewWillMove(toWindow: newWindow) 122 | if #available(macOS 11.0, *) { } else { 123 | podcastUnplayedCountTrailingConstraint.constant = 8 124 | } 125 | } 126 | 127 | var loading: Bool = false { 128 | didSet { 129 | needsDisplay = true 130 | podcastUnplayedCount.loading = loading 131 | } 132 | } 133 | 134 | override var backgroundStyle: NSView.BackgroundStyle { 135 | willSet { 136 | if newValue == .dark { 137 | title.textColor = NSColor.white 138 | author.textColor = NSColor.init(white: 0.9, alpha: 1.0) 139 | episodeCount.textColor = NSColor.init(white: 0.9, alpha: 1.0) 140 | podcastUnplayedCount.highlightColor = true 141 | } else { 142 | title.textColor = NSColor.labelColor 143 | author.textColor = NSColor.secondaryLabelColor 144 | episodeCount.textColor = NSColor.secondaryLabelColor 145 | podcastUnplayedCount.highlightColor = false 146 | } 147 | } 148 | } 149 | 150 | } 151 | -------------------------------------------------------------------------------- /Doughnut/Views/SeekSlider.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | final class SeekSlider: NSSlider { 22 | 23 | override class var cellClass: AnyClass? { 24 | get { SeekSliderCell.self } 25 | set {} 26 | } 27 | 28 | override var knobThickness: CGFloat { 29 | get { 30 | return 3.0 31 | } 32 | } 33 | 34 | var streamedValue: Double = 0 { 35 | didSet { 36 | if let cell = cell as? SeekSliderCell { 37 | cell.streamed = streamedValue 38 | } 39 | } 40 | } 41 | 42 | fileprivate(set) var isTracking: Bool = false 43 | 44 | } 45 | 46 | private class SeekSliderCell: NSSliderCell { 47 | 48 | var streamed: Double = 0 49 | 50 | override var knobThickness: CGFloat { 51 | return knobWidth 52 | } 53 | 54 | let knobWidth: CGFloat = 4.0 55 | let knobHeight: CGFloat = 17.0 56 | let knobRadius: CGFloat = 2.0 57 | 58 | fileprivate var isTracking: Bool = false 59 | 60 | override init() { 61 | super.init() 62 | } 63 | 64 | required init(coder aDecoder: NSCoder) { 65 | super.init(coder: aDecoder) 66 | } 67 | 68 | var percentage: CGFloat { 69 | get { 70 | if (self.maxValue - self.minValue) > 0 { 71 | return CGFloat((self.doubleValue - self.minValue) / (self.maxValue - self.minValue)) 72 | } else { 73 | return 0 74 | } 75 | } 76 | } 77 | 78 | var streamedPercentage: CGFloat { 79 | get { 80 | if (self.maxValue - self.minValue) > 0 { 81 | return CGFloat((self.streamed - self.minValue) / (self.maxValue - self.minValue)) 82 | } else { 83 | return 0 84 | } 85 | } 86 | } 87 | 88 | override func drawBar(inside aRect: NSRect, flipped: Bool) { 89 | let progressColor = NSColor(calibratedRed: 0.478, green: 0.478, blue: 0.478, alpha: 1.0) 90 | let baseColor = NSColor(calibratedRed: 0.729, green: 0.729, blue: 0.729, alpha: 1.0) 91 | 92 | var rect = aRect 93 | rect.origin.x += 0.5 94 | rect.origin.y += 0.5 95 | rect.size.height = CGFloat(4) 96 | let barRadius = CGFloat(1) 97 | 98 | var progressRect = rect 99 | progressRect.size.width = CGFloat(percentage * (self.controlView!.frame.size.width - 8)) 100 | 101 | var streamedRect = rect 102 | streamedRect.size.width = CGFloat(streamedPercentage * (self.controlView!.frame.size.width - 8)) 103 | 104 | let bg = NSBezierPath(roundedRect: rect, xRadius: barRadius, yRadius: barRadius) 105 | baseColor.setStroke() 106 | bg.lineWidth = 1.0 107 | bg.stroke() 108 | 109 | let secondary = NSBezierPath(roundedRect: streamedRect, xRadius: barRadius, yRadius: barRadius) 110 | baseColor.setFill() 111 | secondary.fill() 112 | 113 | let active = NSBezierPath(roundedRect: progressRect, xRadius: barRadius, yRadius: barRadius) 114 | progressColor.setFill() 115 | active.fill() 116 | } 117 | 118 | override func drawKnob(_ knobRect: NSRect) { 119 | NSColor.white.setFill() 120 | NSColor(calibratedRed: 0.6, green: 0.6, blue: 0.6, alpha: 1.0).setStroke() 121 | 122 | let rect = CGRect( 123 | x: round(knobRect.origin.x), 124 | y: knobRect.origin.y + 0.5 * (knobRect.height - knobHeight), 125 | width: knobRect.width, 126 | height: knobHeight 127 | ) 128 | let path = NSBezierPath(roundedRect: rect, xRadius: knobRadius, yRadius: knobRadius) 129 | path.fill() 130 | path.stroke() 131 | } 132 | 133 | override func knobRect(flipped: Bool) -> NSRect { 134 | let bounds = super.barRect(flipped: flipped) 135 | let pos = min(percentage * bounds.width, bounds.width - 1) 136 | let rect = super.knobRect(flipped: flipped) 137 | let flippedMultiplier = flipped ? CGFloat(-1) : CGFloat(1) 138 | return CGRect( 139 | x: pos - flippedMultiplier * 0.5 * knobWidth, 140 | y: rect.origin.y, 141 | width: knobWidth, 142 | height: rect.height 143 | ) 144 | } 145 | 146 | override func startTracking(at startPoint: NSPoint, in controlView: NSView) -> Bool { 147 | let slider = controlView as! SeekSlider 148 | slider.isTracking = true 149 | return super.startTracking(at: startPoint, in: controlView) 150 | } 151 | 152 | override func stopTracking(last lastPoint: NSPoint, current stopPoint: NSPoint, in controlView: NSView, mouseIsUp flag: Bool) { 153 | let slider = controlView as! SeekSlider 154 | slider.isTracking = false 155 | super.stopTracking(last: lastPoint, current: stopPoint, in: controlView, mouseIsUp: flag) 156 | } 157 | 158 | } 159 | -------------------------------------------------------------------------------- /Doughnut/Views/SortingMenuProvider.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | enum SortDirection: String, CaseIterable { 22 | case asc = "Ascending" 23 | case desc = "Descending" 24 | } 25 | 26 | enum SortingMenuStyle { 27 | case mainMenu 28 | case pullDownMenu 29 | case actionMenu 30 | } 31 | 32 | protocol SortingMenuProviderDelegate { 33 | 34 | func sorted(by: String?, direction: SortDirection) 35 | 36 | } 37 | 38 | final class SortingMenuProvider { 39 | 40 | struct Shared { 41 | static let podcasts = SortingMenuProvider( 42 | menuItemTitles: [ 43 | PodcastViewController.SortParameter.title.rawValue, 44 | PodcastViewController.SortParameter.episodes.rawValue, 45 | PodcastViewController.SortParameter.favourites.rawValue, 46 | PodcastViewController.SortParameter.recentEpisodes.rawValue, 47 | PodcastViewController.SortParameter.unplayed.rawValue, 48 | ] 49 | ) 50 | static let episodes = SortingMenuProvider( 51 | menuItemTitles: [ 52 | EpisodeViewController.SortParameter.favourites.rawValue, 53 | EpisodeViewController.SortParameter.mostRecent.rawValue, 54 | ] 55 | ) 56 | } 57 | 58 | var delegate: SortingMenuProviderDelegate? 59 | 60 | var menuItemTitles: [String] 61 | 62 | init(menuItemTitles: [String]) { 63 | self.menuItemTitles = menuItemTitles 64 | } 65 | 66 | func build(forStyle style: SortingMenuStyle) -> NSMenu { 67 | let sortMenu = NSMenu() 68 | 69 | let titleItems: [NSMenuItem] = menuItemTitles.map { title in 70 | let item = NSMenuItem(title: title, action: #selector(performSort), keyEquivalent: "") 71 | item.target = self 72 | 73 | if title == sortParam { 74 | item.state = .on 75 | } 76 | return item 77 | } 78 | 79 | let directionMenuItems: [NSMenuItem] = SortDirection.allCases.map { direction in 80 | let item = NSMenuItem(title: direction.rawValue, action: #selector(performSortDirection), keyEquivalent: "") 81 | item.target = self 82 | 83 | if direction == sortDirection { 84 | item.state = .on 85 | } 86 | return item 87 | } 88 | 89 | if style == .actionMenu { 90 | // Entry item for action button menu 91 | let entryItem = NSMenuItem(title: "by \(sortParam ?? "Unknown")", action: nil, keyEquivalent: "") 92 | 93 | let entryMenu = NSMenu() 94 | for item in titleItems { 95 | entryMenu.addItem(item) 96 | } 97 | entryItem.submenu = entryMenu 98 | 99 | sortMenu.addItem(entryItem) 100 | } else { 101 | if style == .pullDownMenu { 102 | // Title item for pull-down button 103 | sortMenu.addItem(NSMenuItem(title: "Sort by \(sortParam ?? "Unknown")", action: nil, keyEquivalent: "")) 104 | } 105 | 106 | for item in titleItems { 107 | sortMenu.addItem(item) 108 | } 109 | } 110 | 111 | sortMenu.addItem(NSMenuItem.separator()) 112 | 113 | for item in directionMenuItems { 114 | sortMenu.addItem(item) 115 | } 116 | 117 | if style == .pullDownMenu { 118 | // Ensure menuItems' title font is consistent with normal menus for 119 | // recessed pull-down button. 120 | for item in sortMenu.items[1...] { 121 | item.configureWithDefaultFont() 122 | } 123 | } 124 | 125 | return sortMenu 126 | } 127 | 128 | var sortParam: String? 129 | 130 | @objc func performSort(_ sender: NSMenuItem) { 131 | sortParam = sender.title 132 | delegate?.sorted(by: sortParam, direction: sortDirection) 133 | } 134 | 135 | var sortDirection: SortDirection = .asc 136 | 137 | @objc func performSortDirection(_ sender: NSMenuItem) { 138 | guard let sortDirection = SortDirection(rawValue: sender.title) else { 139 | return 140 | } 141 | self.sortDirection = sortDirection 142 | delegate?.sorted(by: sortParam, direction: sortDirection) 143 | } 144 | 145 | } 146 | -------------------------------------------------------------------------------- /Doughnut/Views/TaskManagerView.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | class TaskManagerView: NSView, TaskQueueViewDelegate { 22 | let activitySpinner = ActivityIndicator() 23 | let popover = NSPopover() 24 | 25 | var tasksViewController: TasksViewController? 26 | 27 | var hasActiveTasks: Bool = false { 28 | didSet { 29 | activitySpinner.isHidden = !hasActiveTasks 30 | } 31 | } 32 | 33 | required init?(coder decoder: NSCoder) { 34 | super.init(coder: decoder) 35 | 36 | Library.global.tasks.delegate = self 37 | 38 | popover.behavior = .transient 39 | 40 | activitySpinner.frame = self.bounds 41 | activitySpinner.isHidden = true 42 | addSubview(activitySpinner) 43 | 44 | let storyboard = NSStoryboard(name: "Main", bundle: nil) 45 | tasksViewController = (storyboard.instantiateController(withIdentifier: "TasksPopover") as! TasksViewController) 46 | tasksViewController?.loadView() // Important: force load views so they exist even before popover is viewed 47 | popover.contentViewController = tasksViewController 48 | } 49 | 50 | override func mouseDown(with event: NSEvent) { 51 | if hasActiveTasks { 52 | popover.show(relativeTo: bounds, of: activitySpinner, preferredEdge: .minY) 53 | } 54 | } 55 | 56 | func taskPushed(task: Task) { 57 | tasksViewController?.taskPushed(task: task) 58 | } 59 | 60 | func taskFinished(task: Task) { 61 | tasksViewController?.taskFinished(task: task) 62 | } 63 | 64 | func tasksRunning(_ running: Bool) { 65 | if running { 66 | hasActiveTasks = true 67 | } else { 68 | hasActiveTasks = false 69 | 70 | if popover.isShown { 71 | popover.close() 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Doughnut/WindowController+Toolbar.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | private extension NSToolbarItem.Identifier { 22 | 23 | static let doughnutRefresh = Self("NSToolbarDoughnutRefreshItemIdentifier") 24 | static let doughnutNewPodcast = Self("NSToolbarDoughnutNewPodcastItemIdentifier") 25 | static let doughnutFilter = Self("NSToolbarDoughnutFilterItemIdentifier") 26 | static let doughnutPlayerView = Self("NSToolbarDoughnutPlayerViewItemIdentifier") 27 | static let doughnutTaskManager = Self("NSToolbarDoughnutTaskManagerItemIdentifier") 28 | static let doughnutSearch = Self("NSToolbarDoughnutSearchItemIdentifier") 29 | 30 | } 31 | 32 | extension WindowController: NSToolbarDelegate { 33 | 34 | private static let fixedSizeIdentifiersForCatalina: [NSToolbarItem.Identifier] = [ 35 | .doughnutRefresh, 36 | .doughnutNewPodcast, 37 | ] 38 | 39 | private static let itemsToHideMenu: [NSToolbarItem.Identifier] = [ 40 | .doughnutTaskManager, 41 | .doughnutPlayerView, 42 | ] 43 | 44 | private static let fixedItemSizeForCatalina = CGSize(width: 40, height: 23) 45 | 46 | func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { 47 | if #available(macOS 11.0, *) { 48 | return [ 49 | .flexibleSpace, 50 | .doughnutRefresh, 51 | .doughnutNewPodcast, 52 | .sidebarTrackingSeparator, 53 | .flexibleSpace, 54 | .doughnutFilter, 55 | .doughnutPlayerView, 56 | .doughnutTaskManager, 57 | .flexibleSpace, 58 | .doughnutSearch, 59 | ] 60 | } else { 61 | return [ 62 | .doughnutRefresh, 63 | .doughnutNewPodcast, 64 | .flexibleSpace, 65 | .doughnutPlayerView, 66 | .doughnutTaskManager, 67 | .flexibleSpace, 68 | .doughnutSearch, 69 | ] 70 | } 71 | } 72 | 73 | func toolbarWillAddItem(_ notification: Notification) { 74 | guard let item = notification.userInfo?["item"] as? NSToolbarItem else { 75 | return 76 | } 77 | 78 | if #available(macOS 11.0, *) { } else { 79 | if Self.fixedSizeIdentifiersForCatalina.contains(item.itemIdentifier) { 80 | item.minSize = Self.fixedItemSizeForCatalina 81 | item.maxSize = Self.fixedItemSizeForCatalina 82 | } 83 | } 84 | 85 | if Self.itemsToHideMenu.contains(item.itemIdentifier) { 86 | item.menuFormRepresentation = nil 87 | } 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /Doughnut/WindowController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Cocoa 20 | 21 | final class WindowController: NSWindowController, NSTextFieldDelegate { 22 | 23 | @IBOutlet weak var filterEpisodesToolbarItem: NSToolbarItem! 24 | @IBOutlet weak var playerView: NSToolbarItem! 25 | @IBOutlet weak var searchInputView: NSTextField! 26 | 27 | var viewController: ViewController? { 28 | return contentViewController as? ViewController 29 | } 30 | 31 | var subscribeViewController: SubscribeViewController { 32 | get { 33 | return self.storyboard!.instantiateController(withIdentifier: "SubscribeViewController") as! SubscribeViewController 34 | } 35 | } 36 | 37 | override func windowDidLoad() { 38 | super.windowDidLoad() 39 | 40 | window?.titleVisibility = .hidden 41 | window?.center() 42 | 43 | if #available(macOS 11.0, *) { 44 | window?.toolbarStyle = .unified 45 | } else { 46 | window?.styleMask.remove(.fullSizeContentView) 47 | } 48 | 49 | window?.toolbar?.centeredItemIdentifier = playerView.itemIdentifier 50 | 51 | // https://stackoverflow.com/questions/65723318/how-to-set-initial-width-of-nssearchtoolbaritem 52 | searchInputView.addConstraint( 53 | searchInputView.widthAnchor.constraint(lessThanOrEqualToConstant: 180) 54 | ) 55 | 56 | searchInputView.delegate = self 57 | } 58 | 59 | // Subscribed to Search input changes 60 | func controlTextDidChange(_ obj: Notification) { 61 | if !searchInputView.stringValue.isEmpty { 62 | viewController?.search(searchInputView.stringValue.lowercased()) 63 | } else { 64 | viewController?.search(nil) 65 | } 66 | } 67 | 68 | @IBAction func subscribeToPodcast(_ sender: Any) { 69 | /*let subscribeAlert = NSAlert() 70 | subscribeAlert.messageText = "Podcast feed URL" 71 | subscribeAlert.addButton(withTitle: "Ok") 72 | subscribeAlert.addButton(withTitle: "Cancel") 73 | 74 | let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 300, height: 24)) 75 | input.stringValue = "" 76 | 77 | subscribeAlert.accessoryView = input 78 | let button = subscribeAlert.runModal() 79 | if button == .alertFirstButtonReturn { 80 | Library.global.subscribe(url: input.stringValue) 81 | }*/ 82 | 83 | contentViewController?.presentAsSheet(subscribeViewController) 84 | } 85 | 86 | @IBAction func reloadAll(_ sender: Any) { 87 | Library.global.reloadAll() 88 | } 89 | 90 | @IBAction func newPodcast(_ sender: Any) { 91 | guard let podcastWindowController = ShowPodcastWindowController.instantiateFromMainStoryboard(), 92 | let podcastWindow = podcastWindowController.window 93 | else { 94 | return 95 | } 96 | self.window?.beginSheet(podcastWindow, completionHandler: nil) 97 | } 98 | 99 | @IBAction func showDownloads(_ button: NSButton) { 100 | /*guard let downloadsViewController = self.downloadsViewController else { return } 101 | 102 | let popover = NSPopover() 103 | popover.behavior = .transient 104 | popover.contentViewController = downloadsViewController 105 | popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY)*/ 106 | } 107 | 108 | } 109 | 110 | extension WindowController: NSWindowDelegate { 111 | 112 | func windowDidEnterFullScreen(_ notification: Notification) { 113 | viewController?.updateWindowTitleVisibility() 114 | } 115 | 116 | func windowDidExitFullScreen(_ notification: Notification) { 117 | viewController?.updateWindowTitleVisibility() 118 | } 119 | 120 | func windowDidResignKey(_ notification: Notification) { 121 | if let player = playerView.view as? PlayerView { 122 | player.needsDisplay = true 123 | } 124 | } 125 | 126 | func windowDidBecomeKey(_ notification: Notification) { 127 | if let player = playerView.view as? PlayerView { 128 | player.needsDisplay = true 129 | } 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /Doughnut/Windows/ShowEpisodeWindow.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import AVFoundation 20 | import Cocoa 21 | 22 | final class ShowEpisodeWindowController: NSWindowController { 23 | 24 | static func instantiateFromMainStoryboard() -> ShowEpisodeWindowController? { 25 | return NSStoryboard.init(name: "EpisodeInfo", bundle: nil).instantiateInitialController() 26 | } 27 | 28 | override func windowDidLoad() { 29 | window?.isMovableByWindowBackground = true 30 | window?.titleVisibility = .hidden 31 | window?.styleMask.insert([ .resizable ]) 32 | 33 | window?.standardWindowButton(.closeButton)?.isHidden = true 34 | window?.standardWindowButton(.miniaturizeButton)?.isHidden = true 35 | window?.standardWindowButton(.toolbarButton)?.isHidden = true 36 | window?.standardWindowButton(.zoomButton)?.isHidden = true 37 | } 38 | 39 | } 40 | 41 | class ShowEpisodeWindow: NSWindow { 42 | override var canBecomeKey: Bool { 43 | get { 44 | return true 45 | } 46 | } 47 | } 48 | 49 | class ShowEpisodeViewController: NSViewController { 50 | let defaultPodcastArtwork = NSImage(named: "PodcastPlaceholder") 51 | 52 | @IBOutlet weak var artworkView: NSImageView! 53 | @IBOutlet weak var titleLabelView: NSTextField! 54 | @IBOutlet weak var podcastLabelView: NSTextField! 55 | @IBOutlet weak var authorLabelView: NSTextField! 56 | 57 | @IBOutlet weak var backgroundView: BackgroundView! 58 | 59 | @IBOutlet weak var titleInputView: NSTextField! 60 | @IBOutlet weak var guidInputView: NSTextField! 61 | @IBOutlet weak var descriptionInputView: NSTextField! 62 | @IBOutlet weak var publishedDateInputView: NSDatePicker! 63 | 64 | @IBAction func titleInputEvent(_ sender: NSTextField) { 65 | titleLabelView.stringValue = sender.stringValue 66 | } 67 | 68 | override func viewDidLoad() { 69 | artworkView.wantsLayer = true 70 | artworkView.layer?.borderWidth = 1.0 71 | artworkView.layer?.borderColor = NSColor(calibratedWhite: 0.8, alpha: 1.0).cgColor 72 | artworkView.layer?.cornerRadius = 3.0 73 | artworkView.layer?.masksToBounds = true 74 | 75 | backgroundView.isMovableByViewBackground = false 76 | } 77 | 78 | var episode: Episode? { 79 | didSet { 80 | guard let episode = episode else { return } 81 | 82 | titleLabelView.stringValue = episode.title 83 | titleInputView.stringValue = episode.title 84 | guidInputView.stringValue = episode.guid 85 | descriptionInputView.stringValue = episode.description ?? "" 86 | publishedDateInputView.dateValue = episode.pubDate ?? Date() 87 | 88 | if let podcast = episode.podcast { 89 | podcastLabelView.stringValue = podcast.title 90 | authorLabelView.stringValue = podcast.author ?? "" 91 | 92 | if let artwork = podcast.image { 93 | artworkView.image = artwork 94 | } 95 | } 96 | 97 | if let artwork = episode.artwork { 98 | artworkView.image = artwork 99 | } 100 | } 101 | } 102 | 103 | @IBAction func cancel(_ sender: Any) { 104 | NSApp.stopModal(withCode: .cancel) 105 | view.window?.close() 106 | } 107 | 108 | // Permeate UI input changes to podcat object 109 | func commitChanges(_ episode: Episode) { 110 | episode.title = titleInputView.stringValue 111 | episode.pubDate = publishedDateInputView.dateValue 112 | episode.description = descriptionInputView.stringValue 113 | } 114 | 115 | @IBAction func saveEpisode(_ sender: Any) { 116 | if let episode = episode { 117 | commitChanges(episode) 118 | 119 | if validate() { 120 | Library.global.save(episode: episode) 121 | NSApp.stopModal(withCode: .OK) 122 | view.window?.close() 123 | } 124 | } 125 | } 126 | 127 | func validate() -> Bool { 128 | guard let episode = episode else { return false } 129 | 130 | if let invalid = episode.invalid() { 131 | let alert = NSAlert() 132 | alert.messageText = "Unable to Save Episode" 133 | alert.informativeText = invalid 134 | alert.runModal() 135 | 136 | return false 137 | } else { 138 | return true 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /DoughnutTests/.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - no_direct_standard_out_logs 3 | -------------------------------------------------------------------------------- /DoughnutTests/DoughnutTestCase.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import XCTest 20 | 21 | @testable import Doughnut 22 | 23 | class DoughnutTestCase: XCTestCase { 24 | 25 | func fixtureURL(_ name: String, type: String) -> URL { 26 | let bundle = Bundle(for: Swift.type(of: self)) 27 | let filePath = bundle.path(forResource: name, ofType: type) 28 | return URL(fileURLWithPath: filePath!) 29 | } 30 | 31 | override func setUp() { 32 | super.setUp() 33 | 34 | if !Preference.testEnv() { 35 | fatalError("Not running in test mode") 36 | } 37 | } 38 | 39 | override func tearDown() { 40 | super.tearDown() 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /DoughnutTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /DoughnutTests/LibraryTests/Fixtures/ValidFeed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Test Feed 4 | https://cdyer.co.uk 5 | Lorem ipsum sit amet dolor 6 | dyerc 7 | © 2017 Chris Dyer 8 | 9 | en-us 10 | Mon, 25 Sep 2017 23:30:07 GMT 11 | Mon, 25 Sep 2017 23:30:07 GMT 12 | 13 | 14 | No 15 | 16 | 17 | Lorem ipsum sit 18 | Lorem ipsum sit amet dolor 19 | 20 | dyerc 21 | 22 | 23 | dyerc 24 | 25 | 1:05 26 | Test Podcast Episode #2 27 | https://github.com/dyerc/Doughnut#2 28 | Lorem ipsum sit amet dolor 29 | 30 | Comedy 31 | Mon, 25 Sep 2017 23:30:07 GMT 32 | 33 | 34 | 35 | dyerc 36 | 37 | 38 | 1:05 39 | Test Podcast Episode #1 40 | https://github.com/dyerc/Doughnut#1 41 | Lorem ipsum sit amet dolor 42 | News 43 | Sun, 24 Sep 2017 23:30:07 GMT 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /DoughnutTests/LibraryTests/Fixtures/ValidFeedx3.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test Feed 5 | https://cdyer.co.uk 6 | Lorem ipsum sit amet dolor 7 | dyerc 8 | © 2017 Chris Dyer 9 | 10 | en-us 11 | Mon, 25 Sep 2017 23:30:07 GMT 12 | Mon, 25 Sep 2017 23:30:07 GMT 13 | 14 | 15 | No 16 | 17 | 18 | Lorem ipsum sit 19 | Lorem ipsum sit amet dolor 20 | 21 | dyerc 22 | 23 | 24 | dyerc 25 | 26 | 2:25 27 | Test Podcast Episode #3 28 | https://github.com/dyerc/Doughnut#3 29 | Lorem ipsum sit amet dolor 30 | 31 | Comedy 32 | Mon, 25 Sep 2017 23:40:07 GMT 33 | 34 | 35 | 36 | dyerc 37 | 38 | 1:05 39 | Test Podcast Episode #2 Edited 40 | https://github.com/dyerc/Doughnut#2 41 | Lorem ipsum sit amet dolor 42 | 43 | Comedy 44 | Mon, 25 Sep 2017 23:30:07 GMT 45 | 46 | 47 | 48 | dyerc 49 | 50 | 51 | 1:05 52 | Test Podcast Episode #1 53 | https://github.com/dyerc/Doughnut#1 54 | Lorem ipsum sit amet dolor 55 | News 56 | Sun, 24 Sep 2017 23:30:07 GMT 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /DoughnutTests/LibraryTests/Fixtures/enclosure.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/DoughnutTests/LibraryTests/Fixtures/enclosure.mp3 -------------------------------------------------------------------------------- /DoughnutTests/LibraryTests/Fixtures/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/DoughnutTests/LibraryTests/Fixtures/image.jpg -------------------------------------------------------------------------------- /DoughnutTests/LibraryTests/LibrarySpyDelegate.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import XCTest 20 | 21 | @testable import Doughnut 22 | 23 | class LibrarySpyDelegate: LibraryDelegate { 24 | var subscribedToPodcastExpectation: XCTestExpectation? 25 | var subscribedToPodcastResult: Podcast? 26 | func librarySubscribedToPodcast(subscribed: Podcast) { 27 | guard let expectation = subscribedToPodcastExpectation else { return } 28 | self.subscribedToPodcastResult = subscribed 29 | expectation.fulfill() 30 | } 31 | 32 | func libraryReloaded() { 33 | 34 | } 35 | 36 | var updatedPodcastExpectation: XCTestExpectation? 37 | var updatedPodcastResults = [Podcast]() 38 | func libraryUpdatedPodcasts(podcasts: [Podcast]) { 39 | guard let expectation = updatedPodcastExpectation else { return } 40 | updatedPodcastResults = podcasts 41 | expectation.fulfill() 42 | } 43 | 44 | var updatedEpisodeExpectation: XCTestExpectation? 45 | var updatedEpisodeResults = [Episode]() 46 | func libraryUpdatedEpisodes(episodes: [Episode]) { 47 | guard let expectation = updatedEpisodeExpectation else { return } 48 | updatedEpisodeResults = episodes 49 | expectation.fulfill() 50 | } 51 | 52 | var unsubscribedPodcastExpectation: XCTestExpectation? 53 | var unsubscribedPodcastResult: Podcast? 54 | func libraryUnsubscribedFromPodcast(unsubscribed: Podcast) { 55 | guard let expectation = unsubscribedPodcastExpectation else { return } 56 | unsubscribedPodcastResult = unsubscribed 57 | expectation.fulfill() 58 | } 59 | 60 | func libraryUpdatingPodcasts(podcasts: [Podcast]) { 61 | 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /DoughnutTests/LibraryTests/LibraryTestCase.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import XCTest 20 | 21 | @testable import Doughnut 22 | 23 | class LibraryTestCase: DoughnutTestCase { 24 | 25 | override func setUp() { 26 | super.setUp() 27 | 28 | XCTAssertEqual(Library.global.connect(), true) 29 | print("Using library at \(Library.global.path)") 30 | } 31 | 32 | override func tearDown() { 33 | super.tearDown() 34 | 35 | do { 36 | try Library.global.dbQueue?.inDatabase({ db in 37 | try Podcast.deleteAll(db) 38 | try Episode.deleteAll(db) 39 | }) 40 | } catch let error as NSError { 41 | fatalError("Failed to remove database \(error.debugDescription)") 42 | } 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /DoughnutTests/LibraryTests/LibraryTestsWithoutSubscriptions.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import XCTest 20 | 21 | @testable import Doughnut 22 | 23 | import GRDB 24 | 25 | class LibraryTestsWithoutSubscription: LibraryTestCase { 26 | var library: Library? 27 | 28 | func testSubscribe() { 29 | let expectation = self.expectation(description: "Library calls didSubscribeToPodcast") 30 | let spy = LibrarySpyDelegate() 31 | Library.global.delegate = spy 32 | spy.subscribedToPodcastExpectation = expectation 33 | 34 | Library.global.subscribe(url: fixtureURL("ValidFeed", type: "xml").absoluteString) 35 | 36 | self.waitForExpectations(timeout: 1) { error in 37 | if let error = error { 38 | XCTFail("\(error)") 39 | } 40 | 41 | guard let sub = spy.subscribedToPodcastResult else { 42 | XCTFail("Expected delegate to be called") 43 | return 44 | } 45 | 46 | XCTAssertEqual(sub.title, "Test Feed") 47 | XCTAssertEqual(sub.author, "dyerc") 48 | XCTAssertGreaterThan(sub.id!, 0) 49 | XCTAssertEqual(sub.episodes.count, 2) 50 | 51 | let pod = Library.global.podcast(id: sub.id!) 52 | XCTAssertEqual(pod!.title, sub.title) 53 | } 54 | } 55 | 56 | func testSubscribeWithoutFeed() { 57 | let expectation = self.expectation(description: "Library calls didSubscribeToPodcast") 58 | let spy = LibrarySpyDelegate() 59 | Library.global.delegate = spy 60 | spy.subscribedToPodcastExpectation = expectation 61 | 62 | let podcast = Podcast(title: "New Podcast") 63 | podcast.author = "No Feed" 64 | 65 | Library.global.subscribe(podcast: podcast) 66 | 67 | self.waitForExpectations(timeout: 1) { error in 68 | if let error = error { 69 | XCTFail("\(error)") 70 | } 71 | 72 | guard let sub = spy.subscribedToPodcastResult else { 73 | XCTFail("Expected delegate to be called") 74 | return 75 | } 76 | 77 | XCTAssertEqual(sub.title, "New Podcast") 78 | XCTAssertEqual(sub.author, "No Feed") 79 | XCTAssertGreaterThan(sub.id!, 0) 80 | XCTAssertEqual(sub.episodes.count, 0) 81 | 82 | let pod = Library.global.podcast(id: sub.id!) 83 | XCTAssertEqual(pod!.title, sub.title) 84 | } 85 | } 86 | 87 | func testSanitizeFilePath() { 88 | 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /DoughnutTests/LibraryTests/LibraryUtils.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import Foundation 20 | import XCTest 21 | 22 | @testable import Doughnut 23 | 24 | class LibraryUtils: XCTestCase { 25 | func testExtractsItunesPodcastId() { 26 | XCTAssertEqual(Utils.iTunesPodcastId(iTunesUrl: "https://itunes.apple.com/gb/podcast/tell-em-steve-dave/id357537542?mt=2"), "357537542") 27 | } 28 | 29 | func testExtractsFeedUrlFromItunes() { 30 | let exp = expectation(description: "Parses iTunes data") 31 | 32 | _ = Utils.iTunesFeedUrl(iTunesUrl: "https://itunes.apple.com/gb/podcast/tell-em-steve-dave/id357537542?mt=2") { feedUrl in 33 | XCTAssertEqual(feedUrl, "http://feeds.feedburner.com/TellEmSteveDave") 34 | exp.fulfill() 35 | } 36 | 37 | wait(for: [exp], timeout: 10) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /DoughnutTests/ModelTests/EpisodeModelTests.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import XCTest 20 | 21 | @testable import Doughnut 22 | 23 | import GRDB 24 | 25 | final class EpisodeModelTests: ModelTestCase { 26 | 27 | private func fetchEpisode(withId id: Int64) throws -> Episode { 28 | let episode: Episode = try dbQueue.inDatabase { db in 29 | guard let episode = try Episode.fetchOne(db, key: id) else { 30 | XCTFail("Episode.fetchOne(_:) returns nil.") 31 | return nil 32 | } 33 | return episode 34 | }! 35 | return episode 36 | } 37 | 38 | func testReadEpisodeFromDB() { 39 | do { 40 | let episode: Episode = try dbQueue.inDatabase { db in 41 | guard let episode = try Episode.fetchOne(db, key: 1) else { 42 | XCTFail("Episode.fetchOne(_:) returns nil.") 43 | return nil 44 | } 45 | return episode 46 | }! 47 | 48 | let dateFormatter = ISO8601DateFormatter() 49 | 50 | XCTAssertEqual(episode.id, 1) 51 | XCTAssertEqual(episode.podcastId, 1) 52 | XCTAssertEqual(episode.title, "Test Podcast Episode #2") 53 | XCTAssertEqual(episode.description, "Lorem ipsum sit amet dolor") 54 | XCTAssertEqual(episode.guid, "https://github.com/dyerc/Doughnut#2") 55 | XCTAssertEqual(episode.pubDate, dateFormatter.date(from: "2017-09-25T23:30:07Z")) 56 | XCTAssertEqual(episode.link, "https://cdyer.co.uk") 57 | XCTAssertEqual(episode.enclosureUrl, "enclosure.mp3") 58 | XCTAssertEqual(episode.enclosureSize, 1037273) 59 | XCTAssertEqual(episode.fileName, "Lorem ipsum sit amet dolor") 60 | XCTAssertEqual(episode.favourite, true) 61 | XCTAssertEqual(episode.downloaded, true) 62 | XCTAssertEqual(episode.played, true) 63 | XCTAssertEqual(episode.playPosition, 708) 64 | XCTAssertEqual(episode.duration, 3221) 65 | } catch { 66 | XCTFail("\(#function) fail with error: \(error)") 67 | } 68 | } 69 | 70 | func testWriteEpisodeToDB() { 71 | do { 72 | let episodeRead = try fetchEpisode(withId: 1) 73 | 74 | episodeRead.id = nil 75 | try dbQueue.write{ db in 76 | try episodeRead.insert(db) 77 | } 78 | 79 | let episodeSaved = try fetchEpisode(withId: 4) 80 | 81 | XCTAssertEqual(episodeSaved.id, 4) 82 | XCTAssertEqual(episodeSaved.podcastId, episodeRead.podcastId) 83 | XCTAssertEqual(episodeSaved.title, episodeRead.title) 84 | XCTAssertEqual(episodeSaved.description, episodeRead.description) 85 | XCTAssertEqual(episodeSaved.guid, episodeRead.guid) 86 | XCTAssertEqual(episodeSaved.pubDate, episodeRead.pubDate) 87 | XCTAssertEqual(episodeSaved.link, episodeRead.link) 88 | XCTAssertEqual(episodeSaved.enclosureUrl, episodeRead.enclosureUrl) 89 | XCTAssertEqual(episodeSaved.enclosureSize, episodeRead.enclosureSize) 90 | XCTAssertEqual(episodeSaved.fileName, episodeRead.fileName) 91 | XCTAssertEqual(episodeSaved.favourite, episodeRead.favourite) 92 | XCTAssertEqual(episodeSaved.downloaded, episodeRead.downloaded) 93 | XCTAssertEqual(episodeSaved.played, episodeRead.played) 94 | XCTAssertEqual(episodeSaved.playPosition, episodeRead.playPosition) 95 | XCTAssertEqual(episodeSaved.duration, episodeRead.duration) 96 | } catch { 97 | XCTFail("\(#function) fail with error: \(error)") 98 | } 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /DoughnutTests/ModelTests/Fixtures/ModelTests.dnl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/DoughnutTests/ModelTests/Fixtures/ModelTests.dnl -------------------------------------------------------------------------------- /DoughnutTests/ModelTests/ModelTestCase.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import XCTest 20 | 21 | @testable import Doughnut 22 | 23 | import GRDB 24 | 25 | class ModelTestCase: DoughnutTestCase { 26 | 27 | var dbQueue: DatabaseQueue! 28 | 29 | override func setUp() { 30 | super.setUp() 31 | 32 | do { 33 | let libraryPath = Preference.libraryPath() 34 | let modelTestsFilePath = libraryPath.appendingPathComponent("ModelTests.dnl").path 35 | if FileManager.default.fileExists(atPath: modelTestsFilePath) { 36 | try FileManager.default.removeItem(atPath: modelTestsFilePath) 37 | } 38 | try FileManager.default.copyItem(atPath: fixtureURL("ModelTests", type: "dnl").path, toPath: modelTestsFilePath) 39 | 40 | dbQueue = try DatabaseQueue(path: modelTestsFilePath) 41 | } catch { 42 | fatalError("ModelTestCase: failed to setup with error: \(error)") 43 | } 44 | } 45 | 46 | override func tearDown() { 47 | super.tearDown() 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /DoughnutTests/ModelTests/PodcastModelTests.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import XCTest 20 | 21 | @testable import Doughnut 22 | 23 | import GRDB 24 | 25 | final class PodcastModelTests: ModelTestCase { 26 | 27 | private func fetchPodcast(withId id: Int64) throws -> Podcast { 28 | let podcast: Podcast = try dbQueue.inDatabase { db in 29 | guard let podcast = try Podcast.fetchOne(db, key: id) else { 30 | XCTFail("Podcast.fetchOne(_:) returns nil.") 31 | return nil 32 | } 33 | podcast.loadEpisodes(db: db) 34 | return podcast 35 | }! 36 | return podcast 37 | } 38 | 39 | func testReadPodcastFromDB() { 40 | do { 41 | let podcast = try fetchPodcast(withId: 1) 42 | 43 | let dateFormatter = ISO8601DateFormatter() 44 | 45 | XCTAssertEqual(podcast.id, 1) 46 | XCTAssertEqual(podcast.title, "Test Feed") 47 | XCTAssertEqual(podcast.path, "Test Feed") 48 | XCTAssertEqual(podcast.feed, "http://localhost/ValidFeed.xml") 49 | XCTAssertEqual(podcast.description, "Lorem ipsum sit amet dolor") 50 | XCTAssertEqual(podcast.link, "https://cdyer.co.uk") 51 | XCTAssertEqual(podcast.author, "dyerc") 52 | XCTAssertEqual(podcast.language, "en-us") 53 | XCTAssertEqual(podcast.copyright, "© 2017 Chris Dyer") 54 | XCTAssertEqual(podcast.pubDate, dateFormatter.date(from: "2017-09-25T23:30:07Z")) 55 | 56 | let imageRepresentation = podcast.image?.representations.first 57 | XCTAssertNotNil(imageRepresentation) 58 | XCTAssertEqual(imageRepresentation?.pixelsHigh, 100) 59 | XCTAssertEqual(imageRepresentation?.pixelsWide, 100) 60 | 61 | XCTAssertEqual(podcast.imageUrl, "http://localhost/image.jpg") 62 | XCTAssertEqual(podcast.lastParsed, dateFormatter.date(from: "2022-03-20T08:09:09Z")) 63 | XCTAssertEqual(podcast.subscribedAt, dateFormatter.date(from: "2022-03-20T08:09:09Z")) 64 | XCTAssertEqual(podcast.autoDownload, true) 65 | XCTAssertEqual(podcast.reloadFrequency, 10) 66 | 67 | XCTAssertEqual(podcast.manualReload, false) 68 | XCTAssertEqual(podcast.defaultReload, false) 69 | 70 | XCTAssertEqual(podcast.episodes.map { $0.id }, [1, 2, 3]) 71 | XCTAssertEqual(podcast.unplayedCount, 1) 72 | XCTAssertEqual(podcast.favouriteCount, 2) 73 | XCTAssertEqual(podcast.latestEpisode?.id, 3) 74 | } catch { 75 | XCTFail("\(#function) fail with error: \(error)") 76 | } 77 | } 78 | 79 | func testWritePodcastToDB() { 80 | do { 81 | let podcastRead = try fetchPodcast(withId: 1) 82 | 83 | podcastRead.id = nil 84 | try dbQueue.write{ db in 85 | try podcastRead.insert(db) 86 | } 87 | 88 | let podcastSaved = try fetchPodcast(withId: 2) 89 | 90 | XCTAssertEqual(podcastSaved.id, 2) 91 | XCTAssertEqual(podcastSaved.title, podcastRead.title) 92 | XCTAssertEqual(podcastSaved.path, podcastRead.path) 93 | XCTAssertEqual(podcastSaved.feed, podcastRead.feed) 94 | XCTAssertEqual(podcastSaved.description, podcastRead.description) 95 | XCTAssertEqual(podcastSaved.link, podcastRead.link) 96 | XCTAssertEqual(podcastSaved.author, podcastRead.author) 97 | XCTAssertEqual(podcastSaved.language, podcastRead.language) 98 | XCTAssertEqual(podcastSaved.copyright, podcastRead.copyright) 99 | XCTAssertEqual(podcastSaved.pubDate, podcastRead.pubDate) 100 | XCTAssertEqual(podcastSaved.image?.size, podcastRead.image?.size) 101 | XCTAssertEqual(podcastSaved.imageUrl, podcastRead.imageUrl) 102 | XCTAssertEqual(podcastSaved.lastParsed, podcastRead.lastParsed) 103 | XCTAssertEqual(podcastSaved.subscribedAt, podcastRead.subscribedAt) 104 | XCTAssertEqual(podcastSaved.autoDownload, podcastRead.autoDownload) 105 | XCTAssertEqual(podcastSaved.reloadFrequency, podcastRead.reloadFrequency) 106 | 107 | XCTAssertEqual(podcastRead.manualReload, podcastSaved.manualReload) 108 | XCTAssertEqual(podcastRead.defaultReload, podcastSaved.defaultReload) 109 | 110 | XCTAssert(podcastSaved.episodes.isEmpty) 111 | XCTAssertEqual(podcastSaved.unplayedCount, 0) 112 | XCTAssertEqual(podcastSaved.favouriteCount, 0) 113 | XCTAssertNil(podcastSaved.latestEpisode) 114 | } catch { 115 | XCTFail("\(#function) fail with error: \(error)") 116 | } 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /DoughnutUITests/DoughnutUITests.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import XCTest 20 | 21 | @testable import Doughnut 22 | 23 | class DoughnutUITests: XCTestCase { 24 | override func setUp() { 25 | super.setUp() 26 | 27 | // Put setup code here. This method is called before the invocation of each test method in the class. 28 | 29 | // In UI tests it is usually best to stop immediately when a failure occurs. 30 | continueAfterFailure = false 31 | 32 | // UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method. 33 | let app = XCUIApplication() 34 | app.launchArguments += ["UI-TEST"] 35 | app.launch() 36 | } 37 | 38 | override func tearDown() { 39 | // Put teardown code here. This method is called after the invocation of each test method in the class. 40 | super.tearDown() 41 | } 42 | 43 | func testNewPodcast() { 44 | 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /DoughnutUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | BNDL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /DoughnutUITests/PodcastUITests.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Doughnut Podcast Client 3 | * Copyright (C) 2017 - 2022 Chris Dyer 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | import XCTest 20 | 21 | class PodcastUITests: XCTestCase { 22 | func createsBlankPodcast() { 23 | 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment the next line to define a global platform for your project 2 | platform :osx, '10.15' 3 | 4 | target 'Doughnut' do 5 | # Comment the next line if you're not using Swift and don't want to use dynamic frameworks 6 | use_frameworks! :linkage => :static 7 | 8 | # Pods for Doughnut 9 | pod 'GRDB.swift', '5.17.0' 10 | pod 'FeedKit', '9.1.2' 11 | pod 'MASPreferences', '1.4.1' 12 | pod 'Sparkle', '1.27.1' 13 | pod 'PLCrashReporter', '1.10.1' 14 | end 15 | 16 | target 'DoughnutTests' do 17 | use_frameworks! 18 | inherit! :search_paths 19 | # Pods for testing 20 | end 21 | 22 | target 'DoughnutUITests' do 23 | use_frameworks! 24 | inherit! :search_paths 25 | # Pods for testing 26 | end 27 | 28 | post_install do |installer| 29 | installer.pods_project.targets.each do |target| 30 | target.build_configurations.each do |config| 31 | config.build_settings.delete 'ARCHS' 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - FeedKit (9.1.2) 3 | - GRDB.swift (5.17.0): 4 | - GRDB.swift/standard (= 5.17.0) 5 | - GRDB.swift/standard (5.17.0) 6 | - MASPreferences (1.4.1) 7 | - PLCrashReporter (1.10.1) 8 | - Sparkle (1.27.1) 9 | 10 | DEPENDENCIES: 11 | - FeedKit (= 9.1.2) 12 | - GRDB.swift (= 5.17.0) 13 | - MASPreferences (= 1.4.1) 14 | - PLCrashReporter (= 1.10.1) 15 | - Sparkle (= 1.27.1) 16 | 17 | SPEC REPOS: 18 | trunk: 19 | - FeedKit 20 | - GRDB.swift 21 | - MASPreferences 22 | - PLCrashReporter 23 | - Sparkle 24 | 25 | SPEC CHECKSUMS: 26 | FeedKit: 71653273ab08e618cd6fd1301ca08fc02dca6a9e 27 | GRDB.swift: 1c8a479b2723beab39ed8609fe25513483a0f282 28 | MASPreferences: 1ba2deb14086792857af44d22846fc4aae477fd9 29 | PLCrashReporter: b30195e509f07299ea277d1997b3a39449d05698 30 | Sparkle: 23f98b268284c8c03e6228230fc8f1807ef041d5 31 | 32 | PODFILE CHECKSUM: e8bcc1c22483a4ca906087def86d07aa5c4b34da 33 | 34 | COCOAPODS: 1.11.3 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Doughnut 3 |
4 | Doughnut 5 |
6 |

7 | 8 |

Podcast app. For Mac.

9 | 10 |

11 | Mentioned in Awesome 12 | Github Release 13 |

14 | 15 |

16 | brew install --cask doughnut 17 |

18 | 19 |

20 | screenshot 21 |

22 | 23 | Doughnut is a podcast client built using Swift. The design and user experience are inspired by Instacast for Mac which was discontinued in 2015. After experimenting with alternate user interface layouts, I kept coming back to the three column layout as most useable and practical. 24 | 25 | Beyond the standard expected podcast app features, my goals for the project are: 26 | - [x] Support an iTunes style library that can be hosted on an internal or network shared drive 27 | - [x] Ability to favourite episodes 28 | - [x] Ability to create podcasts without a feed, for miscellaneous releases of discontinued podcasts 29 | 30 | Previously Doughnut was built on top of Electron which worked ok, but using 200+ MB for a podcast app, even when it's minimized felt very poor. Doughnut is now written as a 100% native MacOS app in Swift. 31 | 32 | ## How to Contribute 33 | 34 | ### Local Environments 35 | 36 | * Xcode 12.2+, latest stable release is recommended, but not required. 37 | 38 | * Install [SwiftLint](https://github.com/realm/SwiftLint). 39 | 40 | ```shell 41 | brew install swiftlint 42 | ``` 43 | 44 | ### Get the code 45 | 46 | ``` 47 | $ git clone git@github.com:dyerc/Doughnut.git 48 | $ cd Doughnut 49 | $ pod install 50 | $ open Doughnut.xcworkspace 51 | ``` 52 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dyerc/Doughnut/e5d625b9a2ef8cff0bc4e2a4dc9656184ea3184f/screenshot.png --------------------------------------------------------------------------------