From 18d77ad50f89085056f39297cb8fab75665f4d16 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 21 Apr 2024 23:50:08 +0000 Subject: [PATCH 1/2] Update dependency macos to v14 --- .github/workflows/push.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index ef06831..d175e88 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -3,7 +3,7 @@ on: push jobs: lint: - runs-on: macos-13 + runs-on: macos-14 timeout-minutes: 10 name: Lint steps: @@ -35,7 +35,7 @@ jobs: test: needs: lint timeout-minutes: 30 - runs-on: macos-13 + runs-on: macos-14 name: Test steps: - uses: maxim-lobanov/setup-xcode@v1 From 3d5351d1f31ff954c6f37f8c25b8212670a3556e Mon Sep 17 00:00:00 2001 From: github-actions <22269397+maiyama18@users.noreply.github.com> Date: Sun, 21 Apr 2024 23:51:05 +0000 Subject: [PATCH 2/2] Apply lint --- Apps/iOSAppProd/iOSAppProd/ContentView.swift | 2 +- Package.swift | 8 +- Sources/App/iOSApp/AppDelegate.swift | 4 +- Sources/App/iOSApp/iOSApp.swift | 4 +- .../EpisodePlayingStateRecord+Extension.swift | 24 ++-- .../Database/EpisodeRecord+Extension.swift | 14 +-- .../Core/Database/PersistentProvider.swift | 22 ++-- .../Core/Database/ShowRecord+Extension.swift | 12 +- Sources/Core/Entity/SearchedShow.swift | 2 +- Sources/Core/Extension/PreferenceKeys.swift | 2 +- .../DuplicatedRecordsDeleteUseCase.swift | 16 +-- .../NavigationState/NavigationState.swift | 16 +-- .../PodcastChapterExtractUseCase.swift | 6 +- .../ShowCreateUseCase/ShowCreateUseCase.swift | 6 +- .../ShowEpisodesUpdateUseCase.swift | 4 +- .../ShowSearchUseCase/ShowSearchUseCase.swift | 4 +- .../Data/SoundFileState/SoundFileState.swift | 36 +++--- .../SoundPlayerState/SoundPlayerState.swift | 118 +++++++++--------- .../DebugFeature/Log/DebugLogScreen.swift | 10 +- .../EpisodeDetailScreen.swift | 18 +-- .../FeedFeature/Screens/FeedScreen.swift | 14 +-- .../Components/ShowRowView.swift | 2 +- .../Screens/ShowListScreen.swift | 10 +- .../Screens/ShowSearchScreen.swift | 12 +- .../MainTabFeature/MainTabScreen.swift | 8 +- .../PlayerFeature/PlayerBannerView.swift | 18 +-- .../PlayerEpisodeDetailScreen.swift | 12 +- .../PlayerFeature/PlayerMainScreen.swift | 60 ++++----- .../PlayerFeature/PlayerModifier.swift | 2 +- .../Feature/PlayerFeature/PlayerSheet.swift | 6 +- .../Screens/ShowDetailScreen.swift | 18 +-- Sources/Infra/RSSClient/RSSShow.swift | 2 +- .../StoredSoundPlayerState.swift | 2 +- .../UI/Components/EpisodeActionButton.swift | 14 +-- Sources/UI/Components/EpisodeRowView.swift | 18 +-- Sources/UI/Components/HTMLView.swift | 24 ++-- .../UI/Components/ProgressSystemImage.swift | 46 +++---- .../DuplicatedRecordsDeleteUseCaseTests.swift | 14 +-- 38 files changed, 305 insertions(+), 305 deletions(-) diff --git a/Apps/iOSAppProd/iOSAppProd/ContentView.swift b/Apps/iOSAppProd/iOSAppProd/ContentView.swift index a7af6fb..f4530af 100644 --- a/Apps/iOSAppProd/iOSAppProd/ContentView.swift +++ b/Apps/iOSAppProd/iOSAppProd/ContentView.swift @@ -3,7 +3,7 @@ // iOSAppProd // // Created by maiyama18 on 2023/08/29 -// +// // import SwiftUI diff --git a/Package.swift b/Package.swift index 9782423..eb576da 100644 --- a/Package.swift +++ b/Package.swift @@ -208,7 +208,7 @@ let targets: [PackageDescription.Target] = [ ], path: "Sources/Feature/PlayerFeature" ), - + // UI module .target( @@ -301,11 +301,11 @@ let targets: [PackageDescription.Target] = [ .algorithms, "Entity", "RSSClient", - "ITunesClient" + "ITunesClient", ], path: "Sources/Data/ShowSearchUseCase" ), - + // Infra module .target( @@ -408,7 +408,7 @@ let targets: [PackageDescription.Target] = [ name: "DeepLink", dependencies: [ .dependencies, - "Environment" + "Environment", ], path: "Sources/Core/DeepLink" ), diff --git a/Sources/App/iOSApp/AppDelegate.swift b/Sources/App/iOSApp/AppDelegate.swift index 93ef68d..d14d716 100644 --- a/Sources/App/iOSApp/AppDelegate.swift +++ b/Sources/App/iOSApp/AppDelegate.swift @@ -7,7 +7,7 @@ import UIKit final class AppDelegate: NSObject, UIApplicationDelegate { @Dependency(\.logger[.app]) private var logger @Dependency(\.duplicatedRecordsDeleteUseCase) private var duplicatedRecordsDeleteUseCase - + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) @@ -18,7 +18,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { } return true } - + func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String) async { logger.notice("handleEventsForBackgroundURLSession") } diff --git a/Sources/App/iOSApp/iOSApp.swift b/Sources/App/iOSApp/iOSApp.swift index cc2eac0..4e4ca84 100644 --- a/Sources/App/iOSApp/iOSApp.swift +++ b/Sources/App/iOSApp/iOSApp.swift @@ -10,7 +10,7 @@ import SwiftUI public struct IOSApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate - + private let navigationState: NavigationState = .shared private let soundFileState: SoundFileState = .shared private let soundPlayerState: SoundPlayerState = .shared @@ -18,7 +18,7 @@ public struct IOSApp: App { @Dependency(\.logger[.app]) private var logger @Dependency(\.duplicatedRecordsDeleteUseCase) private var duplicatedRecordsDeleteUseCase - + public init() {} public var body: some Scene { diff --git a/Sources/Core/Database/EpisodePlayingStateRecord+Extension.swift b/Sources/Core/Database/EpisodePlayingStateRecord+Extension.swift index 202cd95..c7ab1c4 100644 --- a/Sources/Core/Database/EpisodePlayingStateRecord+Extension.swift +++ b/Sources/Core/Database/EpisodePlayingStateRecord+Extension.swift @@ -3,59 +3,59 @@ import Dependencies extension EpisodePlayingStateRecord { struct RecordNotFound: Error {} - + public static func withEpisodeID(_ episodeID: String) -> NSFetchRequest { let request = EpisodePlayingStateRecord.fetchRequest() request.predicate = NSPredicate(format: "episode.id_ == %@", episodeID) return request } - + @MainActor public func startPlaying(atTime: TimeInterval) throws { @Dependency(\.date.now) var now - + guard let episode else { throw RecordNotFound() } isCompleted = false isPlaying = true willFinishedAt = now.addingTimeInterval(episode.duration - atTime) lastPausedTime = 0 - + try save() } - + @MainActor public func pause(atTime: TimeInterval) throws { isPlaying = false willFinishedAt = nil lastPausedTime = atTime - + try save() } - + @MainActor public func move(to time: TimeInterval) throws { @Dependency(\.date.now) var now - + guard let episode else { throw RecordNotFound() } if isPlaying { willFinishedAt = now.addingTimeInterval(episode.duration - time) } else { lastPausedTime = time } - + try save() } - + @MainActor public func complete() throws { isPlaying = false isCompleted = true willFinishedAt = nil lastPausedTime = 0 - + try save() } - + private func save() throws { guard let context = managedObjectContext else { throw NSError(domain: "no context", code: 0) diff --git a/Sources/Core/Database/EpisodeRecord+Extension.swift b/Sources/Core/Database/EpisodeRecord+Extension.swift index 74c1705..0983357 100644 --- a/Sources/Core/Database/EpisodeRecord+Extension.swift +++ b/Sources/Core/Database/EpisodeRecord+Extension.swift @@ -9,7 +9,7 @@ extension EpisodeRecord { request.predicate = NSPredicate(format: "id_ == %@", id) return request } - + @MainActor public static func followed() -> NSFetchRequest { let request = EpisodeRecord.fetchRequest() @@ -17,19 +17,19 @@ extension EpisodeRecord { request.sortDescriptors = [.init(keyPath: \EpisodeRecord.publishedAt_, ascending: false)] return request } - + public static func withShowFeedURL(_ url: URL) -> NSFetchRequest { let request = EpisodeRecord.fetchRequest() request.predicate = NSPredicate(format: "show.feedURL_ == %@", url as CVarArg) request.sortDescriptors = [.init(keyPath: \EpisodeRecord.publishedAt_, ascending: false)] return request } - + public var id: String { id_ ?? "" } public var title: String { title_ ?? "" } public var soundURL: URL { soundURL_! } public var publishedAt: Date { publishedAt_ ?? .now } - + public convenience init( context: NSManagedObjectContext = PersistentProvider.cloud.viewContext, id: String, @@ -41,7 +41,7 @@ extension EpisodeRecord { publishedAt: Date ) { self.init(context: context) - + self.id_ = id self.title_ = title self.subtitle = subtitle @@ -70,7 +70,7 @@ extension EpisodeRecord { soundURL: URL(string: "https://example.com")!, publishedAt: rssDateFormatter.date(from: "Tue, 03 Jan 2023 20:00:00 -0800")! ) - + episode.show = ShowRecord( context: context, title: "Rebuild", @@ -80,7 +80,7 @@ extension EpisodeRecord { imageURL: URL(string: "https://cdn.rebuild.fm/images/icon1400.jpg")!, linkURL: URL(string: "https://rebuild.fm") ) - + return episode } } diff --git a/Sources/Core/Database/PersistentProvider.swift b/Sources/Core/Database/PersistentProvider.swift index 4a96287..da46768 100644 --- a/Sources/Core/Database/PersistentProvider.swift +++ b/Sources/Core/Database/PersistentProvider.swift @@ -8,7 +8,7 @@ public final class PersistentProvider { public static let cloud: PersistentProvider = .init( persistentContainer: makePersistentCloudKitContainer(containerIdentifier: "iCloud.com.muijp.DropcastDev") ) - + public static let inMemory: PersistentProvider = .init( persistentContainer: { let model = NSManagedObjectModel(contentsOf: Bundle.module.url(forResource: "Model", withExtension: "momd")!)! @@ -17,37 +17,37 @@ public final class PersistentProvider { return container }() ) - + private static func storeURL() -> URL { let storeDirectory = NSPersistentCloudKitContainer.defaultDirectoryURL() return storeDirectory.appendingPathComponent("Synced.sqlite") } - + private static func makePersistentCloudKitContainer(containerIdentifier: String) -> NSPersistentContainer { @Dependency(\.logger[.database]) var logger - + let model = NSManagedObjectModel(contentsOf: Bundle.module.url(forResource: "Model", withExtension: "momd")!)! let container = NSPersistentCloudKitContainer(name: "Model", managedObjectModel: model) - + let storeURL = Self.storeURL() logger.notice("store url: \(storeURL, privacy: .public)") let description = NSPersistentStoreDescription(url: storeURL) description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(containerIdentifier: containerIdentifier) description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) - + container.persistentStoreDescriptions = [description] - + container.viewContext.automaticallyMergesChangesFromParent = true - + return container } - + public var viewContext: NSManagedObjectContext { persistentContainer.viewContext } public var managedObjectModel: NSManagedObjectModel { persistentContainer.managedObjectModel } - + private let persistentContainer: LockIsolated - + private init(persistentContainer: NSPersistentContainer) { self.persistentContainer = LockIsolated(persistentContainer) self.persistentContainer.withValue { container in diff --git a/Sources/Core/Database/ShowRecord+Extension.swift b/Sources/Core/Database/ShowRecord+Extension.swift index ba0f810..46a386b 100644 --- a/Sources/Core/Database/ShowRecord+Extension.swift +++ b/Sources/Core/Database/ShowRecord+Extension.swift @@ -13,7 +13,7 @@ extension ShowRecord { request.sortDescriptors = [] return request } - + @MainActor public static func followed() -> NSFetchRequest { let request = ShowRecord.fetchRequest() @@ -21,7 +21,7 @@ extension ShowRecord { request.sortDescriptors = [.init(keyPath: \ShowRecord.title_, ascending: true)] return request } - + @MainActor public static func deleteAll(context: NSManagedObjectContext, feedURL: URL) throws { for show in try context.fetch(Self.withFeedURL(feedURL)) { @@ -34,7 +34,7 @@ extension ShowRecord { throw error } } - + public var title: String { title_! } public var feedURL: URL { feedURL_! } public var imageURL: URL { imageURL_! } @@ -42,7 +42,7 @@ extension ShowRecord { guard let episodes = episodes_ as? Set else { return [] } return Array(episodes) } - + public convenience init( context: NSManagedObjectContext = PersistentProvider.cloud.viewContext, title: String, @@ -53,7 +53,7 @@ extension ShowRecord { linkURL: URL? ) { self.init(context: context) - + self.title_ = title self.showDescription = description self.author = author @@ -61,7 +61,7 @@ extension ShowRecord { self.imageURL_ = imageURL self.linkURL = linkURL } - + @MainActor public func toggleFollow() { followed = !followed diff --git a/Sources/Core/Entity/SearchedShow.swift b/Sources/Core/Entity/SearchedShow.swift index eafaa55..8ae8bc0 100644 --- a/Sources/Core/Entity/SearchedShow.swift +++ b/Sources/Core/Entity/SearchedShow.swift @@ -5,7 +5,7 @@ public struct SearchedShow: Sendable, Equatable { public let imageURL: URL public let title: String public let author: String? - + public init(feedURL: URL, imageURL: URL, title: String, author: String?) { self.feedURL = feedURL self.imageURL = imageURL diff --git a/Sources/Core/Extension/PreferenceKeys.swift b/Sources/Core/Extension/PreferenceKeys.swift index 80ebe78..b1a38c0 100644 --- a/Sources/Core/Extension/PreferenceKeys.swift +++ b/Sources/Core/Extension/PreferenceKeys.swift @@ -2,7 +2,7 @@ import SwiftUI public struct PlayerBannerHeightKey: PreferenceKey { public static let defaultValue: Double = 0 - + public static func reduce(value: inout Double, nextValue: () -> Double) { value = max(value, nextValue()) } diff --git a/Sources/Data/DuplicatedRecordsDeleteUseCase/DuplicatedRecordsDeleteUseCase.swift b/Sources/Data/DuplicatedRecordsDeleteUseCase/DuplicatedRecordsDeleteUseCase.swift index ab60367..ce2a2e8 100644 --- a/Sources/Data/DuplicatedRecordsDeleteUseCase/DuplicatedRecordsDeleteUseCase.swift +++ b/Sources/Data/DuplicatedRecordsDeleteUseCase/DuplicatedRecordsDeleteUseCase.swift @@ -15,13 +15,13 @@ extension DuplicatedRecordsDeleteUseCase { let showFeedURL: URL let id: String } - + let allShows = try context.fetch(ShowRecord.fetchRequest()) let allShowFeedURLs = Set(allShows.map(\.feedURL_)) for showFeedURL in allShowFeedURLs { let shows = allShows.filter { $0.feedURL_ == showFeedURL } if shows.count <= 1 { continue } - + // 残したいレコードが先頭に来るようにソートする。 // follow されていて、かつ episodes の数が多いものを優先して残す let sortedShows = shows.sorted { s1, s2 in @@ -31,18 +31,18 @@ extension DuplicatedRecordsDeleteUseCase { if s2.followed && !s1.followed { return false } return s1.episodes.count > s2.episodes.count } - + for show in sortedShows.dropFirst() { context.delete(show) } } - + do { try context.save() } catch { context.rollback() } - + let allEpisodes = try context.fetch(EpisodeRecord.fetchRequest()) let allEpisodeIDs: [EpisodeID] = allEpisodes.compactMap { episode -> EpisodeID? in guard let feedURL = episode.show?.feedURL_ else { return nil } @@ -51,14 +51,14 @@ extension DuplicatedRecordsDeleteUseCase { for episodeID in allEpisodeIDs { let episodes = allEpisodes.filter { $0.show?.feedURL_ == episodeID.showFeedURL && $0.id == episodeID.id } if episodes.count <= 1 { continue } - + let sortedEpisodes = episodes.sorted { $0.publishedAt > $1.publishedAt } - + for episode in sortedEpisodes.dropFirst() { context.delete(episode) } } - + do { try context.save() } catch { diff --git a/Sources/Data/NavigationState/NavigationState.swift b/Sources/Data/NavigationState/NavigationState.swift index 2d56f32..04ea4f0 100644 --- a/Sources/Data/NavigationState/NavigationState.swift +++ b/Sources/Data/NavigationState/NavigationState.swift @@ -4,29 +4,29 @@ import SwiftUI @Observable public final class NavigationState { public static let shared = NavigationState() - + public var mainTab: MainTab = .feed - + public var playerSheetModeOn: Bool = false - + // feed tab public var feedPath: [PodcastRoute] = [] - + // library tab public var showListPath: [PodcastRoute] = [] - + public var showSearchPath: [PodcastRoute]? = nil - + // settings tab public var settingsPath: [SettingsRoute] = [] - + public func moveToShowDetail(args: ShowDetailInitArguments) async { playerSheetModeOn = false showListPath = [] showSearchPath = nil mainTab = .library try? await Task.sleep(for: .milliseconds(300)) - + showListPath.append(.showDetail(args: args)) } } diff --git a/Sources/Data/PodcastChapterExtractUseCase/PodcastChapterExtractUseCase.swift b/Sources/Data/PodcastChapterExtractUseCase/PodcastChapterExtractUseCase.swift index a5e864b..5357b83 100644 --- a/Sources/Data/PodcastChapterExtractUseCase/PodcastChapterExtractUseCase.swift +++ b/Sources/Data/PodcastChapterExtractUseCase/PodcastChapterExtractUseCase.swift @@ -18,14 +18,14 @@ extension PodcastChapterExtractUseCase { PodcastChapterExtractUseCase( extract: { fileURL in let asset = AVAsset(url: fileURL) - + let availableLocales = try await asset.load(.availableChapterLocales) guard let availableLocale = availableLocales.first else { return [] } - + let chapterMetadataList = try await asset.loadChapterMetadataGroups(withTitleLocale: availableLocale) - + var chapters: [Chapter] = [] for metadata in chapterMetadataList { guard let titleItem = metadata.items.first(where: { $0.commonKey == .commonKeyTitle }), diff --git a/Sources/Data/ShowCreateUseCase/ShowCreateUseCase.swift b/Sources/Data/ShowCreateUseCase/ShowCreateUseCase.swift index b7b03ef..3b3891b 100644 --- a/Sources/Data/ShowCreateUseCase/ShowCreateUseCase.swift +++ b/Sources/Data/ShowCreateUseCase/ShowCreateUseCase.swift @@ -11,12 +11,12 @@ public struct ShowCreateUseCase: Sendable { extension ShowCreateUseCase { static func live(context: NSManagedObjectContext) -> ShowCreateUseCase { @Dependency(\.rssClient) var rssClient - + return ShowCreateUseCase( create: { feedURL in let rssShow = try await rssClient.fetch(feedURL).get() try ShowRecord.deleteAll(context: context, feedURL: feedURL) - + let show = ShowRecord( title: rssShow.title, description: rssShow.description, @@ -38,7 +38,7 @@ extension ShowCreateUseCase { ) ) } - + do { try context.save() } catch { diff --git a/Sources/Data/ShowEpisodesUpdateUseCase/ShowEpisodesUpdateUseCase.swift b/Sources/Data/ShowEpisodesUpdateUseCase/ShowEpisodesUpdateUseCase.swift index fff87ca..d2fb02a 100644 --- a/Sources/Data/ShowEpisodesUpdateUseCase/ShowEpisodesUpdateUseCase.swift +++ b/Sources/Data/ShowEpisodesUpdateUseCase/ShowEpisodesUpdateUseCase.swift @@ -10,13 +10,13 @@ public struct ShowEpisodesUpdateUseCase: Sendable { extension ShowEpisodesUpdateUseCase { static var live: ShowEpisodesUpdateUseCase { @Dependency(\.rssClient) var rssClient - + return ShowEpisodesUpdateUseCase( update: { show in guard let context = show.managedObjectContext else { throw NSError(domain: "no context", code: 0) } - + let rssShow = try await rssClient.fetch(show.feedURL).get() let existingEpisodeIDs = Set(show.episodes.map(\.id)) for rssEpisode in rssShow.episodes where !existingEpisodeIDs.contains(rssEpisode.id) { diff --git a/Sources/Data/ShowSearchUseCase/ShowSearchUseCase.swift b/Sources/Data/ShowSearchUseCase/ShowSearchUseCase.swift index 456f89a..cf50af7 100644 --- a/Sources/Data/ShowSearchUseCase/ShowSearchUseCase.swift +++ b/Sources/Data/ShowSearchUseCase/ShowSearchUseCase.swift @@ -13,11 +13,11 @@ extension ShowSearchUseCase { static var live: ShowSearchUseCase { @Dependency(\.iTunesClient) var iTunesClient @Dependency(\.rssClient) var rssClient - + return ShowSearchUseCase( search: { query in guard !query.isEmpty else { return [] } - + if let url = URL(string: query), (url.scheme == "https" || url.scheme == "http") { do { let rssShow = try await rssClient.fetch(url).get() diff --git a/Sources/Data/SoundFileState/SoundFileState.swift b/Sources/Data/SoundFileState/SoundFileState.swift index 05e4b5c..fb5098e 100644 --- a/Sources/Data/SoundFileState/SoundFileState.swift +++ b/Sources/Data/SoundFileState/SoundFileState.swift @@ -38,40 +38,40 @@ public final class SoundFileState: NSObject { guard let data = try? JSONEncoder().encode(self) else { return nil } return String(data: data, encoding: .utf8) } - + func episodeID() -> EpisodeRecord.ID? { String(base64Encoded: idBase64) } } - + struct UnexpectedError: Error {} - + public static let shared: SoundFileState = .init() public static let soundFilesRootDirectoryURL: URL = URL.documentsDirectory.appendingPathComponent("SoundFiles") - + public static func soundFileURL(episode: EpisodeRecord) throws -> URL { guard let identifier = TaskIdentifier(episode: episode) else { throw NSError(domain: "TaskIdentifierCreationFailed", code: 0) } return soundFileURL(identifier: identifier) } - + private static func soundFileURL(identifier: TaskIdentifier) -> URL { soundFileDirectoryURL(identifier: identifier) .appendingPathComponent(identifier.soundFileName) } - + private nonisolated static func soundFileDirectoryURL(identifier: TaskIdentifier) -> URL { soundFilesRootDirectoryURL .appendingPathComponent(identifier.feedURLBase64) .appendingPathComponent(identifier.idBase64) } - + public var downloadStates: [EpisodeRecord.ID: EpisodeDownloadState] = [:] public let downloadErrorPublisher: PassthroughSubject = .init() - + private var tasks: [TaskIdentifier: URLSessionDownloadTask] = [:] - + @ObservationIgnored @Dependency(\.logger[.soundFile]) var logger - + override private init() { super.init() self.initializeDownloadStates() @@ -94,20 +94,20 @@ public final class SoundFileState: NSObject { tasks[identifier] = task task.resume() } - + public func cancelDownload(episode: EpisodeRecord) { downloadStates[episode.id] = .notDownloaded - + guard let identifier = TaskIdentifier(episode: episode) else { return } - + logger.notice("canceling download episode: \(identifier.idBase64) \(episode.title)") - + tasks[identifier]?.cancel() tasks.removeValue(forKey: identifier) } - + private func initializeDownloadStates() { if !FileManager.default.fileExists(atPath: Self.soundFilesRootDirectoryURL.path()) { try? FileManager.default.createDirectory(at: Self.soundFilesRootDirectoryURL, withIntermediateDirectories: true) @@ -161,7 +161,7 @@ extension SoundFileState: URLSessionDownloadDelegate { } return } - + let directoryURL = Self.soundFileDirectoryURL(identifier: identifier) do { try FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true) @@ -174,7 +174,7 @@ extension SoundFileState: URLSessionDownloadDelegate { return } } - + let fileURL = directoryURL.appendingPathComponent(identifier.soundFileName) do { try data.write(to: fileURL) @@ -210,7 +210,7 @@ extension SoundFileState: URLSessionDownloadDelegate { private func handleDelegateError(session: URLSession, error: Error) { downloadErrorPublisher.send(()) - + guard let identifierString = session.configuration.identifier, let episodeID = TaskIdentifier(string: identifierString)?.episodeID() else { logger.fault("failed to download file and identifier cannot be retrieved: \(error, privacy: .public)") diff --git a/Sources/Data/SoundPlayerState/SoundPlayerState.swift b/Sources/Data/SoundPlayerState/SoundPlayerState.swift index 94546ec..6a0034b 100644 --- a/Sources/Data/SoundPlayerState/SoundPlayerState.swift +++ b/Sources/Data/SoundPlayerState/SoundPlayerState.swift @@ -18,11 +18,11 @@ public final class SoundPlayerState: NSObject { case notPlaying case playing(episode: EpisodeRecord) case pausing(episode: EpisodeRecord) - + public var isPlayingOrPausing: Bool { playingEpisode != nil } - + public var playingEpisode: EpisodeRecord? { switch self { case .notPlaying: @@ -32,7 +32,7 @@ public final class SoundPlayerState: NSObject { } } } - + public enum SpeedRate: Float, CaseIterable { case _0_5 = 0.5 case _0_75 = 0.75 @@ -40,7 +40,7 @@ public final class SoundPlayerState: NSObject { case _1_25 = 1.25 case _1_5 = 1.5 case _1_75 = 1.75 - + public var formatted: String { switch self { case ._0_5: return "0.5x" @@ -54,12 +54,12 @@ public final class SoundPlayerState: NSObject { } public static let shared = SoundPlayerState() - + @ObservationIgnored @Dependency(\.hapticClient) private var hapticClient @ObservationIgnored @Dependency(\.userDefaultsClient) private var userDefaultsClient @ObservationIgnored @Dependency(\.podcastChapterExtractUseCase) private var podcastChapterExtractUseCase - - public var state: State = .notPlaying + + public var state: State = .notPlaying public var currentTimeInt: Int? public var duration: Double? public var speedRate: SpeedRate = ._1 { @@ -78,29 +78,29 @@ public final class SoundPlayerState: NSObject { let progress = (Double(currentTimeInt) - currentChapter.startsAt) / currentChapter.duration return max(min(progress, 1), 0) } - + private var displayLink: CADisplayLink? private var audioPlayer: AVAudioPlayer? = nil private let context: NSManagedObjectContext - + public init(context: NSManagedObjectContext = PersistentProvider.cloud.viewContext) { self.context = context super.init() - + self.speedRate = SpeedRate(rawValue: userDefaultsClient.getSoundPlayerSpeedRate() ?? 1) ?? ._1 restoreCurrentState() configureRemoteCommands() - + NotificationCenter.default.addObserver( self, selector: #selector(handleInterruption), name: AVAudioSession.interruptionNotification, object: nil ) } - + private func configureRemoteCommands() { let center = MPRemoteCommandCenter.shared() - + center.playCommand.isEnabled = true center.playCommand.removeTarget(self) center.playCommand.addTarget { [weak self] _ in @@ -117,7 +117,7 @@ public final class SoundPlayerState: NSObject { } } } - + center.pauseCommand.isEnabled = true center.pauseCommand.removeTarget(self) center.pauseCommand.addTarget { [weak self] _ in @@ -130,7 +130,7 @@ public final class SoundPlayerState: NSObject { return .success } } - + center.skipForwardCommand.isEnabled = true center.skipForwardCommand.preferredIntervals = [10.0] center.skipForwardCommand.removeTarget(self) @@ -139,7 +139,7 @@ public final class SoundPlayerState: NSObject { goForward(seconds: 10) return .success } - + center.skipBackwardCommand.isEnabled = true center.skipBackwardCommand.preferredIntervals = [10.0] center.skipBackwardCommand.removeTarget(self) @@ -149,7 +149,7 @@ public final class SoundPlayerState: NSObject { return .success } } - + private func updateNowPlayingInfo() { let episode: EpisodeRecord switch state { @@ -158,7 +158,7 @@ public final class SoundPlayerState: NSObject { case .playing(let e), .pausing(let e): episode = e } - + var nowPlayingInfo: [String: Any] = [ MPNowPlayingInfoPropertyPlaybackRate: speedRate.rawValue, MPMediaItemPropertyTitle: episode.title, @@ -171,7 +171,7 @@ public final class SoundPlayerState: NSObject { if let show = episode.show { nowPlayingInfo[MPMediaItemPropertyArtist] = show.title nowPlayingInfo[MPMediaItemPropertyPodcastTitle] = show.title - + let imageURL = show.imageURL nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: .init(width: 600, height: 600)) { _ in guard let data = try? Data(contentsOf: imageURL) else { @@ -180,10 +180,10 @@ public final class SoundPlayerState: NSObject { return UIImage(data: data) ?? UIImage() } } - + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo } - + private func storeCurrentState() { let episodeID: EpisodeRecord.ID switch state { @@ -192,10 +192,10 @@ public final class SoundPlayerState: NSObject { case .playing(let episode), .pausing(let episode): episodeID = episode.id } - + userDefaultsClient.setStoredSoundPlayerState(episodeID, audioPlayer?.currentTime ?? 0) } - + private func restoreCurrentState() { guard let storedSoundPlayerState = userDefaultsClient.getStoredSoundPlayerState(), let episode = try? context.fetch(EpisodeRecord.withID(storedSoundPlayerState.episodeID)).first, @@ -203,20 +203,20 @@ public final class SoundPlayerState: NSObject { state = .notPlaying return } - + do { try playingState.pause(atTime: storedSoundPlayerState.currentTime) currentTimeInt = Int(storedSoundPlayerState.currentTime) duration = episode.duration - + state = .pausing(episode: episode) - + let url = try SoundFileState.soundFileURL(episode: episode) - + self.audioPlayer = try configureAudioPlayer(url: url, currentTime: storedSoundPlayerState.currentTime) - + updateNowPlayingInfo() - + Task { chapters = try await podcastChapterExtractUseCase.extract(url) } @@ -224,29 +224,29 @@ public final class SoundPlayerState: NSObject { state = .notPlaying } } - + public func startPlaying(episode: EpisodeRecord) throws { hapticClient.medium() - + // 別のファイルが再生中であれば pause する if case .playing(let episode) = state { pause(episode: episode) } - + let url = try SoundFileState.soundFileURL(episode: episode) - + let playingState = try? context.fetch(EpisodePlayingStateRecord.withEpisodeID(episode.id)).first - + let audioPlayer = try configureAudioPlayer(url: url, currentTime: playingState?.lastPausedTime ?? 0) self.audioPlayer = audioPlayer audioPlayer.play() self.duration = audioPlayer.duration - + validateDisplayLink() - + self.state = .playing(episode: episode) updateNowPlayingInfo() - + if let playingState { try playingState.startPlaying(atTime: audioPlayer.currentTime) } else { @@ -257,22 +257,22 @@ public final class SoundPlayerState: NSObject { } try playingState.startPlaying(atTime: audioPlayer.currentTime) } - + Task { chapters = try await podcastChapterExtractUseCase.extract(url) } } - + public func pause(episode: EpisodeRecord) { hapticClient.medium() - + audioPlayer?.stop() - + invalidateDisplayLink() - + self.state = .pausing(episode: episode) updateNowPlayingInfo() - + guard let playingState = findOrCreatePlayingState(episodeID: episode.id) else { assertionFailure() state = .notPlaying @@ -281,23 +281,23 @@ public final class SoundPlayerState: NSObject { try? playingState.pause(atTime: audioPlayer?.currentTime ?? 0) storeCurrentState() } - + public func goForward(seconds: TimeInterval) { guard let currentTime = audioPlayer?.currentTime else { return } hapticClient.medium() move(to: currentTime + seconds) } - + public func goBackward(seconds: TimeInterval) { guard let currentTime = audioPlayer?.currentTime else { return } hapticClient.medium() move(to: currentTime - seconds) } - + public func goToChapter(chapter: Chapter) { move(to: chapter.startsAt + 0.5) } - + public func move(to time: TimeInterval) { guard let audioPlayer else { return } switch state { @@ -313,7 +313,7 @@ public final class SoundPlayerState: NSObject { nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = clampedTime MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo didDisplayLinkTick() - + Task { let url = try SoundFileState.soundFileURL(episode: episode) chapters = try await podcastChapterExtractUseCase.extract(url) @@ -322,7 +322,7 @@ public final class SoundPlayerState: NSObject { assertionFailure() } } - + private func findOrCreatePlayingState(episodeID: EpisodeRecord.ID) -> EpisodePlayingStateRecord? { if let playingState = try? context.fetch(EpisodePlayingStateRecord.withEpisodeID(episodeID)).first { return playingState @@ -334,17 +334,17 @@ public final class SoundPlayerState: NSObject { } return nil } - + private func validateDisplayLink() { displayLink = CADisplayLink(target: self, selector: #selector(didDisplayLinkTick)) displayLink?.add(to: .main, forMode: .common) } - + private func invalidateDisplayLink() { displayLink?.invalidate() displayLink = nil } - + private func configureAudioPlayer(url: URL, currentTime: TimeInterval) throws -> AVAudioPlayer { let audioPlayer = try AVAudioPlayer(contentsOf: url) audioPlayer.delegate = self @@ -353,12 +353,12 @@ public final class SoundPlayerState: NSObject { audioPlayer.currentTime = currentTime return audioPlayer } - + @objc private func didDisplayLinkTick() { currentTimeInt = Int(audioPlayer?.currentTime ?? 0) storeCurrentState() } - + @objc private func handleInterruption() { if case .playing(let episode) = state { pause(episode: episode) @@ -373,29 +373,29 @@ extension SoundPlayerState: AVAudioPlayerDelegate { self.audioPlayer = nil self.duration = nil invalidateDisplayLink() - + defer { state = .notPlaying } guard case .playing(let episode) = state, let playingState = episode.playingState else { return } - + try? playingState.complete() } } - + nonisolated public func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { player.stop() Task { @MainActor in self.audioPlayer = nil invalidateDisplayLink() - + defer { state = .notPlaying } guard case .playing(let episode) = state, let playingState = episode.playingState else { return } - + playingState.managedObjectContext?.delete(playingState) } } diff --git a/Sources/Feature/DebugFeature/Log/DebugLogScreen.swift b/Sources/Feature/DebugFeature/Log/DebugLogScreen.swift index 567f11c..a292335 100644 --- a/Sources/Feature/DebugFeature/Log/DebugLogScreen.swift +++ b/Sources/Feature/DebugFeature/Log/DebugLogScreen.swift @@ -8,16 +8,16 @@ struct DebugLogScreen: View { case all case category(LogCategory) } - + @State private var allLogEntries: [LogEntry] = [] @State private var searchScope: SearchScope = .all @State private var query: String = "" @State private var loading: Bool = false - + @Dependency(\.messageClient) private var messageClient - + private let logStore = LogStore() - + private var visibleEntries: [LogEntry] { let scopedEntries: [LogEntry] switch searchScope { @@ -33,7 +33,7 @@ struct DebugLogScreen: View { } return scopedEntries.filter { $0.message.contains(trimmedQuery) } } - + var body: some View { Group { if loading { diff --git a/Sources/Feature/EpisodeDetailFeature/EpisodeDetailScreen.swift b/Sources/Feature/EpisodeDetailFeature/EpisodeDetailScreen.swift index 4543621..09ed228 100644 --- a/Sources/Feature/EpisodeDetailFeature/EpisodeDetailScreen.swift +++ b/Sources/Feature/EpisodeDetailFeature/EpisodeDetailScreen.swift @@ -10,21 +10,21 @@ import WebKit @MainActor public struct EpisodeDetailScreen: View { private let episode: EpisodeRecord - + @Environment(NavigationState.self) private var navigationState @Environment(\.playerBannerHeight) private var playerBannerHeight - + public init(episode: EpisodeRecord) { self.episode = episode } - + public var body: some View { VStack { header - + if let description = episode.episodeDescription { Divider() - + HTMLView(htmlBodyString: description, contentBottomInset: playerBannerHeight) } } @@ -35,7 +35,7 @@ public struct EpisodeDetailScreen: View { } } } - + private var header: some View { HStack(alignment: .top) { if let showImageURL = episode.show?.imageURL { @@ -50,7 +50,7 @@ public struct EpisodeDetailScreen: View { .frame(width: 84, height: 84) .cornerRadius(8) } - + VStack(alignment: .leading, spacing: 4) { HStack(spacing: 0) { Text(episode.publishedAt.formatted(date: .numeric, time: .omitted)) @@ -58,11 +58,11 @@ public struct EpisodeDetailScreen: View { Text(formatEpisodeDuration(duration: episode.duration)) } .font(.footnote.monospacedDigit()) - + Text(episode.title) .font(.headline.bold()) .lineLimit(3) - + if let show = episode.show { Button(action: { Task { diff --git a/Sources/Feature/FeedFeature/Screens/FeedScreen.swift b/Sources/Feature/FeedFeature/Screens/FeedScreen.swift index d80d21b..7c9fba6 100644 --- a/Sources/Feature/FeedFeature/Screens/FeedScreen.swift +++ b/Sources/Feature/FeedFeature/Screens/FeedScreen.swift @@ -21,20 +21,20 @@ import UserDefaultsClient public struct FeedScreen: View { @FetchRequest(fetchRequest: EpisodeRecord.followed()) private var episodes: FetchedResults @FetchRequest(fetchRequest: ShowRecord.followed()) private var shows: FetchedResults - + @Environment(NavigationState.self) private var navigationState @Environment(\.openURL) private var openURL @Environment(\.managedObjectContext) private var context @Environment(\.playerBannerHeight) private var playerBannerHeight - + @Dependency(\.messageClient) private var messageClient @Dependency(\.rssClient) private var rssClient @Dependency(\.userDefaultsClient) private var userDefaultsClient - + @Dependency(\.showEpisodesUpdateUseCase) private var showEpisodesUpdateUseCase - + public init() {} - + public var body: some View { NavigationStack(path: .init(get: { navigationState.feedPath }, set: { navigationState.feedPath = $0 })) { Group { @@ -64,7 +64,7 @@ public struct FeedScreen: View { showsImage: true ) } - + EpisodeDivider() } } @@ -111,7 +111,7 @@ private extension FeedScreen { } } } - + userDefaultsClient.setFeedRefreshedAt(.now) } } diff --git a/Sources/Feature/LibraryFeature/Components/ShowRowView.swift b/Sources/Feature/LibraryFeature/Components/ShowRowView.swift index 0c97bd8..af5aa16 100644 --- a/Sources/Feature/LibraryFeature/Components/ShowRowView.swift +++ b/Sources/Feature/LibraryFeature/Components/ShowRowView.swift @@ -7,7 +7,7 @@ struct ShowRowView: View { let imageURL: URL let title: String let author: String? - + var body: some View { HStack(spacing: 12) { LazyImage(url: imageURL) { state in diff --git a/Sources/Feature/LibraryFeature/Screens/ShowListScreen.swift b/Sources/Feature/LibraryFeature/Screens/ShowListScreen.swift index 71b7a8a..f4ad279 100644 --- a/Sources/Feature/LibraryFeature/Screens/ShowListScreen.swift +++ b/Sources/Feature/LibraryFeature/Screens/ShowListScreen.swift @@ -12,15 +12,15 @@ import SwiftUI @MainActor public struct ShowListScreen: View { @FetchRequest(fetchRequest: ShowRecord.followed()) private var shows: FetchedResults - + @Environment(NavigationState.self) private var navigationState @Environment(\.managedObjectContext) private var context @Environment(\.playerBannerHeight) private var playerBannerHeight - + @Dependency(\.messageClient) private var messageClient - + public init() {} - + public var body: some View { NavigationStack(path: .init(get: { navigationState.showListPath }, set: { navigationState.showListPath = $0 })) { Group { @@ -70,7 +70,7 @@ public struct ShowListScreen: View { .tint(.red) } } - + Spacer() .frame(height: playerBannerHeight) .listRowInsets(EdgeInsets()) diff --git a/Sources/Feature/LibraryFeature/Screens/ShowSearchScreen.swift b/Sources/Feature/LibraryFeature/Screens/ShowSearchScreen.swift index a5d6b43..b524fac 100644 --- a/Sources/Feature/LibraryFeature/Screens/ShowSearchScreen.swift +++ b/Sources/Feature/LibraryFeature/Screens/ShowSearchScreen.swift @@ -26,7 +26,7 @@ struct ShowSearchScreen: View { return shows } } - + var searching: Bool { switch self { case .loading: @@ -36,14 +36,14 @@ struct ShowSearchScreen: View { } } } - + @State private var searchState: SearchState = .prompt @State private var query: String = "" @State private var searchTask: Task? = nil - + @Environment(NavigationState.self) private var navigationState @Environment(\.colorScheme) private var colorScheme - + @Dependency(\.messageClient) private var messageClient @Dependency(\.showSearchUseCase) private var showSearchUseCase @@ -150,13 +150,13 @@ private extension ShowSearchScreen { return Color(.systemGray6) } } - + func search() async { guard !query.isEmpty else { searchState = .prompt return } - + searchState = .loading(shows: searchState.currentShows) do { let shows = try await showSearchUseCase.search(query) diff --git a/Sources/Feature/MainTabFeature/MainTabScreen.swift b/Sources/Feature/MainTabFeature/MainTabScreen.swift index d897c83..46341a5 100644 --- a/Sources/Feature/MainTabFeature/MainTabScreen.swift +++ b/Sources/Feature/MainTabFeature/MainTabScreen.swift @@ -15,9 +15,9 @@ import NukeUI public struct MainTabScreen: View { @Environment(NavigationState.self) private var navigationState @Environment(SoundFileState.self) private var soundFileState - + @Dependency(\.messageClient) private var messageClient - + @State private var playerBannerHeight: Double = 0 public init() {} @@ -30,13 +30,13 @@ public struct MainTabScreen: View { Label(title: { Text("Feed", bundle: .module) }, icon: { Image(systemName: "dot.radiowaves.up.forward") }) } .tag(MainTab.feed) - + ShowListScreen() .tabItem { Label(title: { Text("Library", bundle: .module) }, icon: { Image(systemName: "square.stack.3d.down.right") }) } .tag(MainTab.library) - + SettingsScreen() .tabItem { Label(title: { Text("Settings", bundle: .module) }, icon: { Image(systemName: "gearshape") }) diff --git a/Sources/Feature/PlayerFeature/PlayerBannerView.swift b/Sources/Feature/PlayerFeature/PlayerBannerView.swift index 8961d08..01f6bd4 100644 --- a/Sources/Feature/PlayerFeature/PlayerBannerView.swift +++ b/Sources/Feature/PlayerFeature/PlayerBannerView.swift @@ -15,9 +15,9 @@ import SwiftUI @MainActor public struct PlayerBannerView: View { @Environment(SoundPlayerState.self) private var soundPlayerState - + public init() {} - + public var body: some View { switch soundPlayerState.state { case .notPlaying: @@ -28,7 +28,7 @@ public struct PlayerBannerView: View { bannerView(playing: true, episode: episode) } } - + private func bannerView(playing: Bool, episode: EpisodeRecord) -> some View { HStack { LazyImage(url: episode.show?.imageURL) { state in @@ -41,12 +41,12 @@ public struct PlayerBannerView: View { } .frame(width: 48, height: 48) .cornerRadius(8) - + VStack(alignment: .leading) { Text(episode.title) .font(.subheadline.bold()) .lineLimit(1) - + HStack(spacing: 2) { Text(formatEpisodeDuration(duration: TimeInterval(soundPlayerState.currentTimeInt ?? 0))) Text("/") @@ -55,16 +55,16 @@ public struct PlayerBannerView: View { .font(.footnote.monospacedDigit()) .foregroundStyle(.secondary) } - + Spacer(minLength: 0) - + HStack { Button(action: { soundPlayerState.goBackward(seconds: 10) }) { Image(systemName: "gobackward.10") .padding(.horizontal, 4) .padding(.vertical, 8) } - + if playing { Button(action: { soundPlayerState.pause(episode: episode) @@ -88,7 +88,7 @@ public struct PlayerBannerView: View { .padding(.vertical, 8) } } - + Button(action: { soundPlayerState.goForward(seconds: 10) }) { Image(systemName: "goforward.10") .padding(.horizontal, 4) diff --git a/Sources/Feature/PlayerFeature/PlayerEpisodeDetailScreen.swift b/Sources/Feature/PlayerFeature/PlayerEpisodeDetailScreen.swift index 135f61d..731fed4 100644 --- a/Sources/Feature/PlayerFeature/PlayerEpisodeDetailScreen.swift +++ b/Sources/Feature/PlayerFeature/PlayerEpisodeDetailScreen.swift @@ -5,22 +5,22 @@ import SwiftUI struct PlayerEpisodeDetailScreen: View { @Environment(NavigationState.self) private var navigationState - + let episode: EpisodeRecord let episodeDescription: String - + var body: some View { VStack { VStack(alignment: .leading, spacing: 4) { Text(episode.publishedAt.formatted(date: .numeric, time: .omitted)) .font(.subheadline) .foregroundStyle(.secondary) - + Text(episode.title) .font(.headline.bold()) .minimumScaleFactor(0.8) .lineLimit(3) - + if let show = episode.show { Button { Task { @@ -41,9 +41,9 @@ struct PlayerEpisodeDetailScreen: View { } } .frame(maxWidth: .infinity, alignment: .leading) - + Divider() - + HTMLView( htmlBodyString: episodeDescription, contentBottomInset: 0 diff --git a/Sources/Feature/PlayerFeature/PlayerMainScreen.swift b/Sources/Feature/PlayerFeature/PlayerMainScreen.swift index 79a280d..68d28dc 100644 --- a/Sources/Feature/PlayerFeature/PlayerMainScreen.swift +++ b/Sources/Feature/PlayerFeature/PlayerMainScreen.swift @@ -12,10 +12,10 @@ import SwiftUI struct PlayerMainScreen: View { @Environment(SoundPlayerState.self) private var soundPlayerState @Environment(NavigationState.self) private var navigationState - + @State private var imageScale: Double = 0.2 @State private var chaptersMenuPresented: Bool = false - + var body: some View { switch soundPlayerState.state { case .notPlaying: @@ -27,11 +27,11 @@ struct PlayerMainScreen: View { sheetView(playing: true, episode: episode) } } - + private func sheetView(playing: Bool, episode: EpisodeRecord) -> some View { VStack { Spacer(minLength: 8) - + lazyImage(show: episode.show) .cornerRadius(8) .aspectRatio(contentMode: .fit) @@ -40,15 +40,15 @@ struct PlayerMainScreen: View { .onAppear { imageScale = 1 } - + Spacer().frame(height: 16) - + VStack(alignment: .leading, spacing: 4) { Text(episode.title) .font(.headline.bold()) .minimumScaleFactor(0.8) .lineLimit(3) - + if let show = episode.show { Button { Task { @@ -69,17 +69,17 @@ struct PlayerMainScreen: View { } } .frame(maxWidth: .infinity, alignment: .leading) - + Spacer(minLength: 8) - + Text(soundPlayerState.currentChapter?.title ?? " ") .foregroundStyle(.secondary) .font(.subheadline.bold()) - + progressView - + actionButtonsView(playing: playing, episode: episode) - + Spacer(minLength: 0) } .padding(.horizontal, 32) @@ -91,7 +91,7 @@ struct PlayerMainScreen: View { } } } - + private var progressView: some View { VStack(spacing: 0) { Slider( @@ -101,28 +101,28 @@ struct PlayerMainScreen: View { ), in: 0...(soundPlayerState.duration ?? 0) ) - + HStack { Text( formatEpisodeDuration( duration: Double(soundPlayerState.currentTimeInt ?? 0) ) ) - + Spacer(minLength: 0) - + Text( "-" + - formatEpisodeDuration( - duration: Double(soundPlayerState.duration ?? 0) - Double(soundPlayerState.currentTimeInt ?? 0) - ) + formatEpisodeDuration( + duration: Double(soundPlayerState.duration ?? 0) - Double(soundPlayerState.currentTimeInt ?? 0) + ) ) } .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } } - + private func actionButtonsView(playing: Bool, episode: EpisodeRecord) -> some View { HStack { Group { @@ -144,12 +144,12 @@ struct PlayerMainScreen: View { } .font(.body.monospacedDigit()) .padding(.vertical) - + Button(action: { soundPlayerState.goBackward(seconds: 10) }) { Image(systemName: "gobackward.10") .padding(.vertical) } - + if playing { Button(action: { soundPlayerState.pause(episode: episode) @@ -172,12 +172,12 @@ struct PlayerMainScreen: View { .padding(.vertical) } } - + Button(action: { soundPlayerState.goForward(seconds: 10) }) { Image(systemName: "goforward.10") .padding(.vertical) } - + Text(SoundPlayerState.SpeedRate._1_75.formatted) .hidden() .font(.body.monospacedDigit()) @@ -200,7 +200,7 @@ struct PlayerMainScreen: View { .tint(.primary) .frame(maxWidth: .infinity) } - + private var chaptersMenu: some View { ZStack { Color.clear @@ -210,7 +210,7 @@ struct PlayerMainScreen: View { chaptersMenuPresented = false } } - + ScrollViewReader { proxy in ScrollView(showsIndicators: false) { VStack { @@ -223,9 +223,9 @@ struct PlayerMainScreen: View { .font(.callout) .lineLimit(2) .multilineTextAlignment(.leading) - + Spacer(minLength: 4) - + Text(formatEpisodeDuration(duration: chapter.duration)) .foregroundStyle(.secondary) .font(.callout.monospacedDigit()) @@ -266,7 +266,7 @@ struct PlayerMainScreen: View { .frame(height: 400) } } - + private func lazyImage(show: ShowRecord?) -> some View { LazyImage(url: show?.imageURL) { state in if let image = state.image { @@ -287,7 +287,7 @@ struct PlayerMainScreen: View { playerState.state = .playing(episode: .fixture(context: PersistentProvider.inMemory.viewContext)) return playerState }() - + return Text("Preview") .sheet(isPresented: .constant(true)) { PlayerMainScreen() diff --git a/Sources/Feature/PlayerFeature/PlayerModifier.swift b/Sources/Feature/PlayerFeature/PlayerModifier.swift index ec484a0..8c33812 100644 --- a/Sources/Feature/PlayerFeature/PlayerModifier.swift +++ b/Sources/Feature/PlayerFeature/PlayerModifier.swift @@ -12,7 +12,7 @@ public extension View { struct PlayerModifier: ViewModifier { @Environment(SoundPlayerState.self) private var soundPlayerState @Environment(NavigationState.self) private var navigationState - + func body(content: Content) -> some View { content .overlay(alignment: .bottom) { diff --git a/Sources/Feature/PlayerFeature/PlayerSheet.swift b/Sources/Feature/PlayerFeature/PlayerSheet.swift index 1eca5b8..dad728f 100644 --- a/Sources/Feature/PlayerFeature/PlayerSheet.swift +++ b/Sources/Feature/PlayerFeature/PlayerSheet.swift @@ -4,13 +4,13 @@ import SwiftUI struct PlayerSheet: View { @Environment(SoundPlayerState.self) private var soundPlayerState - + @State private var backgroundRotationAngle: Angle = .degrees(Double.random(in: 0...360)) - + var body: some View { TabView { PlayerMainScreen() - + if let episode = soundPlayerState.state.playingEpisode, let episodeDescription = episode.episodeDescription { PlayerEpisodeDetailScreen(episode: episode, episodeDescription: episodeDescription) diff --git a/Sources/Feature/ShowDetailFeature/Screens/ShowDetailScreen.swift b/Sources/Feature/ShowDetailFeature/Screens/ShowDetailScreen.swift index d3e35e7..d2d7dbe 100644 --- a/Sources/Feature/ShowDetailFeature/Screens/ShowDetailScreen.swift +++ b/Sources/Feature/ShowDetailFeature/Screens/ShowDetailScreen.swift @@ -18,23 +18,23 @@ public struct ShowDetailScreen: View { private let feedURL: URL private let initialImageURL: URL private let initialTitle: String - + @State private var isFetchingShow: Bool = false @State private var downloadStates: [EpisodeRecord.ID: EpisodeDownloadState]? = nil - + @FetchRequest var showRecords: FetchedResults private var show: ShowRecord? { showRecords.first } - + @FetchRequest var episodeRecords: FetchedResults - + @Environment(\.openURL) private var openURL @Environment(\.managedObjectContext) private var context @Environment(\.playerBannerHeight) private var playerBannerHeight - + @Dependency(\.clipboardClient) private var clipboardClient @Dependency(\.messageClient) private var messageClient @Dependency(\.rssClient) private var rssClient - + @Dependency(\.showEpisodesUpdateUseCase) private var showEpisodesUpdateUseCase @Dependency(\.showCreateUseCase) private var showCreateUseCase @@ -42,7 +42,7 @@ public struct ShowDetailScreen: View { self.feedURL = args.feedURL self.initialImageURL = args.imageURL self.initialTitle = args.title - + self._showRecords = FetchRequest(fetchRequest: ShowRecord.withFeedURL(feedURL)) self._episodeRecords = FetchRequest(fetchRequest: EpisodeRecord.withShowFeedURL(feedURL)) } @@ -84,7 +84,7 @@ public struct ShowDetailScreen: View { showsImage: false ) } - + EpisodeDivider() } } @@ -124,7 +124,7 @@ public struct ShowDetailScreen: View { .task { isFetchingShow = true defer { isFetchingShow = false } - + do { if let show { try await showEpisodesUpdateUseCase.update(show) diff --git a/Sources/Infra/RSSClient/RSSShow.swift b/Sources/Infra/RSSClient/RSSShow.swift index 3e3fdd9..8510aea 100644 --- a/Sources/Infra/RSSClient/RSSShow.swift +++ b/Sources/Infra/RSSClient/RSSShow.swift @@ -9,7 +9,7 @@ public struct RSSShow: Sendable, Equatable { public let description: String? public let author: String? public let linkURL: URL? - + public let episodes: [RSSEpisode] } diff --git a/Sources/Infra/UserDefaultsClient/StoredSoundPlayerState.swift b/Sources/Infra/UserDefaultsClient/StoredSoundPlayerState.swift index cc54de0..e04e8eb 100644 --- a/Sources/Infra/UserDefaultsClient/StoredSoundPlayerState.swift +++ b/Sources/Infra/UserDefaultsClient/StoredSoundPlayerState.swift @@ -6,7 +6,7 @@ public struct StoredSoundPlayerState: Codable, Defaults.Serializable { self.episodeID = episodeID self.currentTime = currentTime } - + public var episodeID: String public var currentTime: TimeInterval } diff --git a/Sources/UI/Components/EpisodeActionButton.swift b/Sources/UI/Components/EpisodeActionButton.swift index 7bfdc1c..9a5c3bb 100644 --- a/Sources/UI/Components/EpisodeActionButton.swift +++ b/Sources/UI/Components/EpisodeActionButton.swift @@ -13,18 +13,18 @@ public struct EpisodeActionButton: View { case playing case pausing } - + private let episode: EpisodeRecord - + @Environment(SoundFileState.self) private var soundFileState @Environment(SoundPlayerState.self) private var soundPlayerState - + @Dependency(\.messageClient) private var messageClient - + public init(episode: EpisodeRecord) { self.episode = episode } - + public var body: some View { Button { switch downloadState { @@ -74,11 +74,11 @@ public struct EpisodeActionButton: View { } } } - + private var downloadState: EpisodeDownloadState { soundFileState.downloadStates[episode.id] ?? .notDownloaded } - + private var soundState: SoundState { switch soundPlayerState.state { case .notPlaying: diff --git a/Sources/UI/Components/EpisodeRowView.swift b/Sources/UI/Components/EpisodeRowView.swift index 0ca3345..fd145e1 100644 --- a/Sources/UI/Components/EpisodeRowView.swift +++ b/Sources/UI/Components/EpisodeRowView.swift @@ -9,7 +9,7 @@ import SwiftUI public struct EpisodeRowView: View { var episode: EpisodeRecord var showsImage: Bool - + public init( episode: EpisodeRecord, showsImage: Bool @@ -17,7 +17,7 @@ public struct EpisodeRowView: View { self.episode = episode self.showsImage = showsImage } - + public var body: some View { HStack(alignment: .top, spacing: 8) { if showsImage, let showImageURL = episode.show?.imageURL { @@ -32,7 +32,7 @@ public struct EpisodeRowView: View { .frame(width: 64, height: 64) .cornerRadius(8) } - + VStack(alignment: .leading, spacing: 4) { HStack(spacing: 0) { Text(episode.publishedAt.formatted(date: .numeric, time: .omitted)) @@ -40,7 +40,7 @@ public struct EpisodeRowView: View { Text(formatEpisodeDuration(duration: episode.duration)) } .font(.footnote.monospacedDigit()) - + Group { if episode.playingState?.isCompleted == true { Text(Image(systemName: "checkmark.circle.fill")) @@ -52,7 +52,7 @@ public struct EpisodeRowView: View { } .font(.body.bold()) .lineLimit(2) - + Group { if let subtitle = episode.subtitle { Text( @@ -71,19 +71,19 @@ public struct EpisodeRowView: View { .font(.footnote) .foregroundStyle(.secondary) .lineLimit(3) - + HStack(spacing: 12) { EpisodeActionButton(episode: episode) .tint(.accentColor) - + Spacer() - + Button { print("misc") } label: { Image(systemName: "plus.circle") } - + Button { print("misc") } label: { diff --git a/Sources/UI/Components/HTMLView.swift b/Sources/UI/Components/HTMLView.swift index 0f69ee8..53f600b 100644 --- a/Sources/UI/Components/HTMLView.swift +++ b/Sources/UI/Components/HTMLView.swift @@ -3,37 +3,37 @@ import SwiftUI public struct HTMLView: UIViewRepresentable { private let htmlBodyString: String private let contentBottomInset: Double - + public init(htmlBodyString: String, contentBottomInset: Double) { self.htmlBodyString = htmlBodyString self.contentBottomInset = contentBottomInset } - + public func makeUIView(context: Context) -> UITextView { let uiTextView = UITextView() - + uiTextView.backgroundColor = .clear uiTextView.isEditable = false - + uiTextView.isScrollEnabled = true uiTextView.showsVerticalScrollIndicator = false uiTextView.setContentHuggingPriority(.defaultLow, for: .vertical) uiTextView.setContentHuggingPriority(.defaultLow, for: .horizontal) uiTextView.setContentCompressionResistancePriority(.required, for: .vertical) uiTextView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - + return uiTextView } - + public func updateUIView(_ uiTextView: UITextView, context: Context) { uiTextView.contentInset.bottom = contentBottomInset - + guard htmlBodyString.starts(with: "<") else { uiTextView.text = htmlBodyString uiTextView.font = UIFont.preferredFont(forTextStyle: .body) return } - + do { let htmlString = html( bodyString: htmlBodyString, @@ -52,7 +52,7 @@ public struct HTMLView: UIViewRepresentable { uiTextView.font = UIFont.preferredFont(forTextStyle: .body) } } - + private func html(bodyString: String, labelColor: UIColor) -> String { return """ @@ -73,15 +73,15 @@ public struct HTMLView: UIViewRepresentable { """ } - + private func hexString(of uiColor: UIColor) -> String { var red: CGFloat = 0 var green: CGFloat = 0 var blue: CGFloat = 0 var alpha: CGFloat = 0 - + uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) - + return String( format: "#%02lX%02lX%02lX%02lX", lroundf(Float(red * 255)), diff --git a/Sources/UI/Components/ProgressSystemImage.swift b/Sources/UI/Components/ProgressSystemImage.swift index a6d6ebc..ac32339 100644 --- a/Sources/UI/Components/ProgressSystemImage.swift +++ b/Sources/UI/Components/ProgressSystemImage.swift @@ -45,33 +45,33 @@ public struct ProgressSystemImage: View { #if DEBUG #Preview { - -VStack { - HStack { - ForEach([0, 0.2, 0.4, 0.6, 0.8, 1], id: \.self) { progress in - ProgressSystemImage( - systemName: "play.circle", - progress: progress, - onColor: .orange, - offColor: .gray.opacity(0.3) - ) + + VStack { + HStack { + ForEach([0, 0.2, 0.4, 0.6, 0.8, 1], id: \.self) { progress in + ProgressSystemImage( + systemName: "play.circle", + progress: progress, + onColor: .orange, + offColor: .gray.opacity(0.3) + ) + } } - } - .font(.title) + .font(.title) - HStack { - ForEach([0, 0.2, 0.4, 0.6, 0.8, 1], id: \.self) { progress in - ProgressSystemImage( - systemName: "pause.circle", - progress: progress, - onColor: .teal, - offColor: .teal.opacity(0.2) - ) + HStack { + ForEach([0, 0.2, 0.4, 0.6, 0.8, 1], id: \.self) { progress in + ProgressSystemImage( + systemName: "pause.circle", + progress: progress, + onColor: .teal, + offColor: .teal.opacity(0.2) + ) + } } + .font(.largeTitle) } - .font(.largeTitle) -} - + } #endif diff --git a/Tests/Data/DuplicatedRecordsDeleteUseCaseTests/DuplicatedRecordsDeleteUseCaseTests.swift b/Tests/Data/DuplicatedRecordsDeleteUseCaseTests/DuplicatedRecordsDeleteUseCaseTests.swift index 28fbc82..108ac5b 100644 --- a/Tests/Data/DuplicatedRecordsDeleteUseCaseTests/DuplicatedRecordsDeleteUseCaseTests.swift +++ b/Tests/Data/DuplicatedRecordsDeleteUseCaseTests/DuplicatedRecordsDeleteUseCaseTests.swift @@ -8,7 +8,7 @@ import XCTest final class DuplicatedRecordsDeleteUseCaseTests: XCTestCase { func test() async throws { let context = PersistentProvider.inMemory.viewContext - + // ShowA _ = fixtureShow( context: context, @@ -23,7 +23,7 @@ final class DuplicatedRecordsDeleteUseCaseTests: XCTestCase { fixtureEpisode(context: context, id: "A-4"), ] ) - + // ShowB1 _ = fixtureShow( context: context, @@ -51,12 +51,12 @@ final class DuplicatedRecordsDeleteUseCaseTests: XCTestCase { fixtureEpisode(context: context, id: "B-1"), ] ) - + try context.save() - + let useCase = DuplicatedRecordsDeleteUseCase.live(context: context) try useCase.delete() - + let shows = (try context.fetch(ShowRecord.fetchRequest())).sorted(by: { $0.feedURL.absoluteString < $1.feedURL.absoluteString }) XCTAssertEqual(shows.count, 2) XCTAssertEqual( @@ -72,7 +72,7 @@ final class DuplicatedRecordsDeleteUseCaseTests: XCTestCase { ["B-1", "B-2", "B-3"] ) } - + private func fixtureShow( context: NSManagedObjectContext, feedURL: URL, @@ -92,7 +92,7 @@ final class DuplicatedRecordsDeleteUseCaseTests: XCTestCase { } return show } - + private func fixtureEpisode(context: NSManagedObjectContext, id: String) -> EpisodeRecord { EpisodeRecord( context: context,