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 |
3 |
4 | Doughnut
5 |
6 |
7 |
8 | Podcast app. For Mac.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | brew install --cask doughnut
17 |
18 |
19 |
20 |
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
--------------------------------------------------------------------------------