From 91adebb094ec24a3370b1e5da56830d906869908 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Fri, 31 Jan 2025 12:03:17 -0500 Subject: [PATCH] Add bookmarks feature --- BookPlayer.xcodeproj/project.pbxproj | 20 ++ .../xcschemes/BookPlayerWidgetsPhone.xcscheme | 1 + BookPlayer/pl.lproj/Localizable.strings | 6 +- BookPlayerWatch/Bookmarks/BookmarksView.swift | 109 +++++++++++ .../Bookmarks/BookmarksViewModel.swift | 128 ++++++++++++ BookPlayerWatch/PlayerToolbarView.swift | 18 +- .../RemoteItemListCellView.swift | 5 +- .../RemoteItemList/RemoteItemListView.swift | 183 +++--------------- .../RemoteItemListViewModel.swift | 147 ++++++++++++++ BookPlayerWatch/RemotePlayerView.swift | 13 +- BookPlayerWatch/RootView.swift | 2 +- .../Lightweight-Models/SimpleBookmark.swift | 5 +- 12 files changed, 476 insertions(+), 161 deletions(-) create mode 100644 BookPlayerWatch/Bookmarks/BookmarksView.swift create mode 100644 BookPlayerWatch/Bookmarks/BookmarksViewModel.swift create mode 100644 BookPlayerWatch/RemoteItemList/RemoteItemListViewModel.swift diff --git a/BookPlayer.xcodeproj/project.pbxproj b/BookPlayer.xcodeproj/project.pbxproj index d07941d8e..c8aec272b 100644 --- a/BookPlayer.xcodeproj/project.pbxproj +++ b/BookPlayer.xcodeproj/project.pbxproj @@ -279,6 +279,8 @@ 41F1A20D254B0A0C0043FCF3 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A20C254B0A0C0043FCF3 /* Sentry */; }; 41F1A228254B0C6C0043FCF3 /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = 41F1A227254B0C6C0043FCF3 /* ZipArchive */; }; 4645F9FD2D1E46AC00A04257 /* SwipeInlineTip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4645F9FC2D1E46AC00A04257 /* SwipeInlineTip.swift */; }; + 465D87522D3195D600A4AA47 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87512D3195D600A4AA47 /* BookmarksView.swift */; }; + 465D87542D31965100A4AA47 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 465D87532D31965100A4AA47 /* BookmarksViewModel.swift */; }; 4689C06D2D270A7100D6C169 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 419B375423B8D5A500128A8F /* Localizable.strings */; }; 46EEDDC92D23154C0063811F /* VoiceOverService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69343D322133844D000C425E /* VoiceOverService.swift */; }; 5126F121258E9F18009965DC /* URL+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5126F120258E9F18009965DC /* URL+BookPlayer.swift */; }; @@ -453,6 +455,7 @@ 63CD85232CE302D200EDBEA8 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63CD85222CE302D200EDBEA8 /* LoginView.swift */; }; 63CD85272CE3064600EDBEA8 /* BP+ErrorAlerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63CD85262CE3064600EDBEA8 /* BP+ErrorAlerts.swift */; }; 63CD85432CE3105300EDBEA8 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63CD85422CE3105300EDBEA8 /* ProfileView.swift */; }; + 63E54C322D494E110040355D /* RemoteItemListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E54C312D494E110040355D /* RemoteItemListViewModel.swift */; }; 63E7DCC02D076185005B5E1F /* View+BookPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E7DCBF2D076185005B5E1F /* View+BookPlayer.swift */; }; 63E893922CAFA89000946CD4 /* BPPlayerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E893912CAFA89000946CD4 /* BPPlayerError.swift */; }; 63E893932CAFA89000946CD4 /* BPPlayerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63E893912CAFA89000946CD4 /* BPPlayerError.swift */; }; @@ -1112,6 +1115,8 @@ 41F898AE2402080C00F58B8A /* ZipArchive.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ZipArchive.framework; path = Carthage/Build/iOS/ZipArchive.framework; sourceTree = ""; }; 41FCA32625E87EC600BFB9E6 /* Audiobook Player 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Audiobook Player 4.xcdatamodel"; sourceTree = ""; }; 4645F9FC2D1E46AC00A04257 /* SwipeInlineTip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeInlineTip.swift; sourceTree = ""; }; + 465D87512D3195D600A4AA47 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = ""; }; + 465D87532D31965100A4AA47 /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = ""; }; 5126F120258E9F18009965DC /* URL+BookPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+BookPlayer.swift"; sourceTree = ""; }; 5CBB29522163A17F00E3A9FF /* ZIPFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ZIPFoundation.framework; path = Carthage/Build/iOS/ZIPFoundation.framework; sourceTree = ""; }; 620C73C7275DA00300D495AA /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = ""; }; @@ -1251,6 +1256,7 @@ 63CD85222CE302D200EDBEA8 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 63CD85262CE3064600EDBEA8 /* BP+ErrorAlerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BP+ErrorAlerts.swift"; sourceTree = ""; }; 63CD85422CE3105300EDBEA8 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + 63E54C312D494E110040355D /* RemoteItemListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteItemListViewModel.swift; sourceTree = ""; }; 63E7DCBF2D076185005B5E1F /* View+BookPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+BookPlayer.swift"; sourceTree = ""; }; 63E893912CAFA89000946CD4 /* BPPlayerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPPlayerError.swift; sourceTree = ""; }; 63E893942CAFAB8F00946CD4 /* PlayerLoaderService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerLoaderService.swift; sourceTree = ""; }; @@ -1679,6 +1685,7 @@ 6334CF202CFE330300F1FA17 /* RefreshableListView.swift */, 63E7DCBF2D076185005B5E1F /* View+BookPlayer.swift */, 4645F9FC2D1E46AC00A04257 /* SwipeInlineTip.swift */, + 465D87502D3195B600A4AA47 /* Bookmarks */, 63CD851B2CE2963600EDBEA8 /* Settings */, 6399D06E2CEBA1F900A2E278 /* RemoteItemList */, 9FA334B427C156DB0064E8EA /* ItemList */, @@ -2067,6 +2074,15 @@ path = iPad; sourceTree = ""; }; + 465D87502D3195B600A4AA47 /* Bookmarks */ = { + isa = PBXGroup; + children = ( + 465D87512D3195D600A4AA47 /* BookmarksView.swift */, + 465D87532D31965100A4AA47 /* BookmarksViewModel.swift */, + ); + path = Bookmarks; + sourceTree = ""; + }; 62793612272CC19E0097837D /* Models */ = { isa = PBXGroup; children = ( @@ -2362,6 +2378,7 @@ isa = PBXGroup; children = ( 6399D06F2CEBA35D00A2E278 /* RemoteItemListView.swift */, + 63E54C312D494E110040355D /* RemoteItemListViewModel.swift */, 6399D0712CEBA37C00A2E278 /* RemoteItemListCellView.swift */, 6334CF1E2CFAD1B700F1FA17 /* RemoteItemCellViewModel.swift */, ); @@ -3507,9 +3524,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 465D87542D31965100A4AA47 /* BookmarksViewModel.swift in Sources */, 9F82DF6927DE93A2001B0EA8 /* SkipIntervalView.swift in Sources */, 6399D0722CEBA37C00A2E278 /* RemoteItemListCellView.swift in Sources */, 6334CF212CFE330300F1FA17 /* RefreshableListView.swift in Sources */, + 63E54C322D494E110040355D /* RemoteItemListViewModel.swift in Sources */, 9FA334B627C15DE30064E8EA /* VolumeView.swift in Sources */, 46EEDDC92D23154C0063811F /* VoiceOverService.swift in Sources */, 6350E46D2CF4315B0077CDC1 /* PlayerLoaderService.swift in Sources */, @@ -3530,6 +3549,7 @@ 418CABB325EF28FC00D8C878 /* MappingModel_v3_to_v4.xcmappingmodel in Sources */, 9F82DF7127DF8203001B0EA8 /* WatchConnectivityService.swift in Sources */, 6350E4742CF4D2660077CDC1 /* PlayerToolbarView.swift in Sources */, + 465D87522D3195D600A4AA47 /* BookmarksView.swift in Sources */, 41A8BAFE227E6C88003C9895 /* Notification+BookPlayerWatchApp.swift in Sources */, 6350E4762CF4F6E90077CDC1 /* PlaybackFullControlsView.swift in Sources */, 6350E46A2CF429760077CDC1 /* SleepTimer.swift in Sources */, diff --git a/BookPlayer.xcodeproj/xcshareddata/xcschemes/BookPlayerWidgetsPhone.xcscheme b/BookPlayer.xcodeproj/xcshareddata/xcschemes/BookPlayerWidgetsPhone.xcscheme index 7741fa6e4..85dc0587b 100644 --- a/BookPlayer.xcodeproj/xcshareddata/xcschemes/BookPlayerWidgetsPhone.xcscheme +++ b/BookPlayer.xcodeproj/xcshareddata/xcschemes/BookPlayerWidgetsPhone.xcscheme @@ -90,6 +90,7 @@ savedToolIdentifier = "" useCustomWorkingDirectory = "NO" debugDocumentVersioning = "YES" + askForAppToLaunch = "Yes" launchAutomaticallySubstyle = "2"> diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index e0e42c81a..ae25a0d8e 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -135,10 +135,10 @@ "voiceover_unknown_author" = "Nieznany autor"; "voiceover_book_info" = "%@ - %@"; "voiceover_book_chapter" = "%@ - %@, rozdział %@"; -"voiceover_rewind_time" = "Przewiń wstecz %@"; +"voiceover_rewind_time" = "Przewiń do tyłu %@"; "voiceover_forward_time" = "Przewiń do przodu %@"; "watchapp_last_played_title" = "Ostatnio odtwarzane"; -"watchapp_refresh_data_title" = "Odśwież dane"; +"watchapp_refresh_data_title" = "Odśwież"; "recent_title" = "Ostatnie"; "carplay_library_error" = "Nie można załadować książek"; "siri_invocation_phrase" = "Kontynuuj moją książkę"; @@ -158,7 +158,7 @@ "voiceover_currently_playing_title" = "Aktualnie odtwarzam %@ - %@"; "voiceover_miniplayer_hint" = "Miniodtwarzacz. Dotknij, aby wyświetlić odtwarzacz"; "voiceover_chapter_time_title" = "Bieżący czas rozdziału: %@"; -"voiceover_dismiss_player_title" = "Wyłącz Odtwarzacz"; +"voiceover_dismiss_player_title" = "Zamknij odtwarzacz"; "sort_most_recent_button" = "Najczęściej Odtwarzane"; "sort_reversed_button" = "Odwróć kolejność"; "voiceover_continue_playback_title" = "Kontynuuj odtwarzanie"; diff --git a/BookPlayerWatch/Bookmarks/BookmarksView.swift b/BookPlayerWatch/Bookmarks/BookmarksView.swift new file mode 100644 index 000000000..9d34a48a2 --- /dev/null +++ b/BookPlayerWatch/Bookmarks/BookmarksView.swift @@ -0,0 +1,109 @@ +// +// BookmarksView.swift +// BookPlayerWatch +// +// Created by GC on 1/10/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import BookPlayerWatchKit +import SwiftUI + +struct BookmarksView: View { + @StateObject var model: BookmarksViewModel + + @State var error: Error? + + @Environment(\.dismiss) var dismiss + + var body: some View { + List { + HStack { + Spacer() + Button { + do { + try model.createBookmark() + } catch { + self.error = error + } + } label: { + Image(systemName: "plus.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + .buttonStyle(PlainButtonStyle()) + Spacer() + } + .frame(height: 24) + .listRowBackground(Color.clear) + + Section { + ForEach(model.userBookmarks) { bookmark in + Button { + model.playerManager.jumpTo(bookmark.time + 0.01, recordBookmark: false) + dismiss() + } label: { + VStack(alignment: .leading) { + Text(TimeParser.formatTime(bookmark.time)) + .foregroundColor(Color.secondary) + .font(.footnote) + if let note = bookmark.note { + Text(note) + } + } + .frame(minHeight: 24) + .padding(.vertical, Spacing.S4) + } + .swipeActions { + Button( + role: .destructive, + action: { model.deleteBookmark(bookmark) }, + label: { + Image(systemName: "trash") + .imageScale(.large) + } + ) + .accessibilityLabel("delete_button".localized) + } + } + } header: { + Text("bookmark_type_user_title".localized) + .foregroundStyle(Color.accentColor) + } + + Section { + ForEach(model.automaticBookmarks) { bookmark in + Button { + model.playerManager.jumpTo(bookmark.time + 0.01, recordBookmark: false) + dismiss() + } label: { + VStack(alignment: .leading) { + HStack { + Text(TimeParser.formatTime(bookmark.time)) + .foregroundColor(Color.secondary) + .font(.footnote) + Spacer() + if let imageName = bookmark.getImageNameForType() { + Image(systemName: imageName) + .foregroundColor(Color.secondary) + } + } + if let note = bookmark.note { + Text(note) + } + } + } + } + } header: { + Text("bookmark_type_automatic_title".localized) + .foregroundStyle(Color.accentColor) + .padding(.top, 10) + } + } + .environment(\.defaultMinListRowHeight, 1) + .customListSectionSpacing(0) + .errorAlert(error: $error) + .navigationTitle("bookmarks_title") + } +} diff --git a/BookPlayerWatch/Bookmarks/BookmarksViewModel.swift b/BookPlayerWatch/Bookmarks/BookmarksViewModel.swift new file mode 100644 index 000000000..a2d91d7fa --- /dev/null +++ b/BookPlayerWatch/Bookmarks/BookmarksViewModel.swift @@ -0,0 +1,128 @@ +// +// BookmarksViewModel.swift +// BookPlayerWatch +// +// Created by GC on 1/10/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import BookPlayerWatchKit +import Combine +import Foundation + +@MainActor +class BookmarksViewModel: ObservableObject { + @Published var automaticBookmarks = [SimpleBookmark]() + @Published var userBookmarks = [SimpleBookmark]() + @Published var selectedBookmarkToDelete: SimpleBookmark? + private var disposeBag = Set() + + let playerManager: PlayerManager + let libraryService: LibraryServiceProtocol + let syncService: SyncServiceProtocol + + init(coreServices: CoreServices) { + self.playerManager = coreServices.playerManager + self.libraryService = coreServices.libraryService + self.syncService = coreServices.syncService + + self.bindCurrentItemObserver() + } + + func bindCurrentItemObserver() { + playerManager.currentItemPublisher() + .receive(on: DispatchQueue.main) + .sink { [weak self] currentItem in + guard let self else { return } + + if let currentItem { + self.automaticBookmarks = self.getAutomaticBookmarks(for: currentItem.relativePath) + self.userBookmarks = self.getUserBookmarks(for: currentItem.relativePath) + self.syncBookmarks(for: currentItem.relativePath) + } else { + self.automaticBookmarks = [] + self.userBookmarks = [] + } + } + .store(in: &disposeBag) + } + + func syncBookmarks(for relativePath: String) { + Task { @MainActor [weak self] in + guard + let self = self, + let bookmarks = try await self.syncService.syncBookmarksList(relativePath: relativePath) + else { return } + + self.userBookmarks = bookmarks + } + } + + func getAutomaticBookmarks(for relativePath: String) -> [SimpleBookmark] { + let playBookmarks = self.libraryService.getBookmarks(of: .play, relativePath: relativePath) ?? [] + let skipBookmarks = self.libraryService.getBookmarks(of: .skip, relativePath: relativePath) ?? [] + + let bookmarks = playBookmarks + skipBookmarks + + return bookmarks.sorted(by: { $0.time < $1.time }) + } + + func getUserBookmarks(for relativePath: String) -> [SimpleBookmark] { + return self.libraryService.getBookmarks(of: .user, relativePath: relativePath) ?? [] + } + + func createBookmark() throws { + guard let currentItem = playerManager.currentItem else { return } + + let currentTime = currentItem.currentTime + + if let bookmark = libraryService.getBookmark( + at: currentTime, + relativePath: currentItem.relativePath, + type: .user + ) { + throw BookmarksAlerts.bookmarkExists(bookmark: bookmark) + } + + if let bookmark = libraryService.createBookmark( + at: floor(currentTime), + relativePath: currentItem.relativePath, + type: .user + ) { + syncService.scheduleSetBookmark( + relativePath: currentItem.relativePath, + time: floor(currentTime), + note: nil + ) + userBookmarks = getUserBookmarks(for: currentItem.relativePath) + throw BookmarksAlerts.bookmarkCreated(bookmark: bookmark) + } else { + throw BookmarksAlerts.fileMissing + } + } + + func deleteBookmark(_ bookmark: SimpleBookmark) { + libraryService.deleteBookmark(bookmark) + userBookmarks = getUserBookmarks(for: bookmark.relativePath) + syncService.scheduleDeleteBookmark(bookmark) + } +} + +enum BookmarksAlerts: LocalizedError { + case bookmarkExists(bookmark: SimpleBookmark) + case bookmarkCreated(bookmark: SimpleBookmark) + case fileMissing + + public var errorDescription: String? { + switch self { + case .bookmarkExists(let bookmark): + let formattedTime = TimeParser.formatTime(bookmark.time) + return String.localizedStringWithFormat("bookmark_exists_title".localized, formattedTime) + case .bookmarkCreated(let bookmark): + let formattedTime = TimeParser.formatTime(bookmark.time) + return String.localizedStringWithFormat("bookmark_created_title".localized, formattedTime) + case .fileMissing: + return "file_missing_title".localized + } + } +} diff --git a/BookPlayerWatch/PlayerToolbarView.swift b/BookPlayerWatch/PlayerToolbarView.swift index a058cc918..bb1da1ae8 100644 --- a/BookPlayerWatch/PlayerToolbarView.swift +++ b/BookPlayerWatch/PlayerToolbarView.swift @@ -53,9 +53,17 @@ final class PlaybackFullControlsViewModel: ObservableObject { } struct PlayerToolbarView: View { + /// PlayerManager needs to be an ObservedObject, otherwise the Published property do not reload properly @ObservedObject var playerManager: PlayerManager @State var isShowingMoreList: Bool = false + let coreServices: CoreServices + + init(coreServices: CoreServices) { + self.coreServices = coreServices + self.playerManager = coreServices.playerManager + } + var body: some View { HStack { Spacer() @@ -71,8 +79,14 @@ struct PlayerToolbarView: View { Spacer() - VolumeView(type: .local) - .accessibilityHidden(true) + NavigationLink( + destination: BookmarksView(model: .init(coreServices: coreServices)) + ) { + ResizeableImageView(name: "bookmark.fill") + .accessibilityLabel("bookmarks_title".localized) + .padding(20) + } + .buttonStyle(PlainButtonStyle()) Spacer() diff --git a/BookPlayerWatch/RemoteItemList/RemoteItemListCellView.swift b/BookPlayerWatch/RemoteItemList/RemoteItemListCellView.swift index c21cfec5c..5aa8bba98 100644 --- a/BookPlayerWatch/RemoteItemList/RemoteItemListCellView.swift +++ b/BookPlayerWatch/RemoteItemList/RemoteItemListCellView.swift @@ -40,7 +40,7 @@ struct RemoteItemListCellView: View { case .notDownloaded: return ". ☁️" case .downloading(let progress): - return "" + return "\(Int(progress * 100))%" case .downloaded: return ". ⌚️" } @@ -110,6 +110,7 @@ struct RemoteItemListCellView: View { Image(systemName: "xmark.circle") .imageScale(.large) } + .accessibilityLabel("cancel_download_title".localized) case .downloaded: Button { do { @@ -121,6 +122,7 @@ struct RemoteItemListCellView: View { Image(systemName: "applewatch.slash") .imageScale(.large) } + .accessibilityLabel("remove_downloaded_file_title".localized) case .notDownloaded: Button { Task { @@ -134,6 +136,7 @@ struct RemoteItemListCellView: View { Image(systemName: "icloud.and.arrow.down.fill") .imageScale(.large) } + .accessibilityLabel("download_title".localized) } } .accessibilityLabel(VoiceOverService.getAccessibilityLabel(for: model.item) + accessibilityDownloadStateLabel) diff --git a/BookPlayerWatch/RemoteItemList/RemoteItemListView.swift b/BookPlayerWatch/RemoteItemList/RemoteItemListView.swift index b974fc901..1e4abf1af 100644 --- a/BookPlayerWatch/RemoteItemList/RemoteItemListView.swift +++ b/BookPlayerWatch/RemoteItemList/RemoteItemListView.swift @@ -12,162 +12,33 @@ import TipKit struct RemoteItemListView: View { @Environment(\.scenePhase) var scenePhase - @ObservedObject var coreServices: CoreServices - @ObservedObject var playerManager: PlayerManager - @State var items: [SimpleLibraryItem] - @State var playingItemParentPath: String? + @StateObject var model: RemoteItemListViewModel @State private var isLoading = false @State private var error: Error? @State var showPlayer = false @State var isRefreshing: Bool = false @State var isFirstLoad = true - let folderRelativePath: String? - - init( - coreServices: CoreServices, - folderRelativePath: String? = nil - ) { - self.coreServices = coreServices - self.playerManager = coreServices.playerManager - let fetchedItems = - coreServices.libraryService.fetchContents( - at: folderRelativePath, - limit: nil, - offset: nil - ) ?? [] - self._items = .init(initialValue: fetchedItems) - let lastItem = coreServices.libraryService.getLastPlayedItems(limit: 1)?.first - self.folderRelativePath = folderRelativePath - - if let lastItem { - self._playingItemParentPath = .init( - initialValue: getPathForParentOfItem(currentPlayingPath: lastItem.relativePath) - ) - } else { - self._playingItemParentPath = .init(initialValue: nil) - } - } - - private func syncListContents(ignoreLastTimestamp: Bool) async { - guard - await coreServices.syncService.canSyncListContents( - at: folderRelativePath, - ignoreLastTimestamp: ignoreLastTimestamp - ) - else { return } - - do { - try await coreServices.syncService.syncListContents(at: folderRelativePath) - } catch BPSyncError.reloadLastBook(let relativePath) { - reloadLastBook(relativePath: relativePath) - } catch BPSyncError.differentLastBook(let relativePath) { - await setSyncedLastPlayedItem(relativePath: relativePath) - } catch { - self.error = error - } - - items = - coreServices.libraryService.fetchContents( - at: folderRelativePath, - limit: nil, - offset: nil - ) ?? [] - - if let lastPlayedItem { - playingItemParentPath = getPathForParentOfItem(currentPlayingPath: lastPlayedItem.relativePath) - } else { - playingItemParentPath = nil - } - } - - @MainActor - private func reloadLastBook(relativePath: String) { - let wasPlaying = playerManager.isPlaying - playerManager.stop() - - Task { @MainActor in - do { - try await coreServices.playerLoaderService.loadPlayer( - relativePath, - autoplay: wasPlaying - ) - } catch { - self.error = error - } - } - } - - @MainActor - private func setSyncedLastPlayedItem(relativePath: String) async { - /// Only continue overriding local book if it's not currently playing - guard playerManager.isPlaying == false else { return } - - await coreServices.syncService.setLibraryLastBook(with: relativePath) - - do { - try await coreServices.playerLoaderService.loadPlayer( - relativePath, - autoplay: false - ) - } catch { - self.error = error - } - } - func getForegroundColor(for item: SimpleLibraryItem) -> Color { - guard let lastPlayedItem else { return .primary } + guard let lastPlayedItem = model.lastPlayedItem else { return .primary } if item.relativePath == lastPlayedItem.relativePath { return .accentColor } - return item.relativePath == playingItemParentPath ? .accentColor : .primary - } - - func getPathForParentOfItem(currentPlayingPath: String) -> String? { - let parentFolders: [String] = currentPlayingPath.allRanges(of: "/") - .map { String(currentPlayingPath.prefix(upTo: $0.lowerBound)) } - .reversed() - - guard let folderRelativePath = self.folderRelativePath else { - return parentFolders.last - } - - guard let index = parentFolders.firstIndex(of: folderRelativePath) else { - return nil - } - - let elementIndex = index - 1 - - guard elementIndex >= 0 else { - return nil - } - - return parentFolders[elementIndex] - } - - var lastPlayedItem: SimpleLibraryItem? { - guard - let currentItem = playerManager.currentItem, - let lastPlayedItem = coreServices.libraryService.getSimpleItem(with: currentItem.relativePath) - else { - return coreServices.libraryService.getLastPlayedItems(limit: 1)?.first - } - - return lastPlayedItem + return item.relativePath == model.playingItemParentPath ? .accentColor : .primary } var body: some View { RefreshableListView(refreshing: $isRefreshing) { - if folderRelativePath == nil { + if model.folderRelativePath == nil { Section { - if let lastPlayedItem { - RemoteItemListCellView(model: .init(item: lastPlayedItem, coreServices: coreServices)) { + if let lastPlayedItem = model.lastPlayedItem { + RemoteItemListCellView(model: .init(item: lastPlayedItem, coreServices: model.coreServices)) { Task { do { isLoading = true - try await coreServices.playerLoaderService.loadPlayer(lastPlayedItem.relativePath, autoplay: true) + try await model.coreServices.playerLoaderService.loadPlayer(lastPlayedItem.relativePath, autoplay: true) showPlayer = true isLoading = false } catch { @@ -186,31 +57,31 @@ struct RemoteItemListView: View { Section { if #available(watchOS 10.0, *), - folderRelativePath == nil, - !items.isEmpty + model.folderRelativePath == nil, + !model.items.isEmpty { TipView(SwipeInlineTip()) .listRowBackground(Color.clear) } - ForEach(items) { item in + ForEach(model.items) { item in if item.type == .folder { NavigationLink { - RemoteItemListView( - coreServices: coreServices, + RemoteItemListView(model: .init( + coreServices: model.coreServices, folderRelativePath: item.relativePath - ) + )) } label: { - RemoteItemListCellView(model: .init(item: item, coreServices: coreServices)) {} + RemoteItemListCellView(model: .init(item: item, coreServices: model.coreServices)) {} .allowsHitTesting(false) .foregroundColor(getForegroundColor(for: item)) } } else { - RemoteItemListCellView(model: .init(item: item, coreServices: coreServices)) { + RemoteItemListCellView(model: .init(item: item, coreServices: model.coreServices)) { Task { do { isLoading = true - try await coreServices.playerLoaderService.loadPlayer(item.relativePath, autoplay: true) + try await model.coreServices.playerLoaderService.loadPlayer(item.relativePath, autoplay: true) showPlayer = true isLoading = false } catch { @@ -222,9 +93,9 @@ struct RemoteItemListView: View { } } } header: { - Text(verbatim: folderRelativePath?.components(separatedBy: "/").last ?? "library_title".localized) + Text(verbatim: model.folderRelativePath?.components(separatedBy: "/").last ?? "library_title".localized) .foregroundStyle(Color.accentColor) - .padding(.top, folderRelativePath == nil ? 10 : 0) + .padding(.top, model.folderRelativePath == nil ? 10 : 0) } /// Create padding at the bottom @@ -238,7 +109,7 @@ struct RemoteItemListView: View { } .ignoresSafeArea(edges: [.bottom]) .background( - NavigationLink(destination: RemotePlayerView(playerManager: coreServices.playerManager), isActive: $showPlayer) { + NavigationLink(destination: RemotePlayerView(coreServices: model.coreServices), isActive: $showPlayer) { EmptyView() } .opacity(0) @@ -265,14 +136,18 @@ struct RemoteItemListView: View { Task { // Delay the task by 1 second to avoid jumping animations try await Task.sleep(nanoseconds: 1_000_000_000) - await syncListContents(ignoreLastTimestamp: true) + do { + try await model.syncListContents(ignoreLastTimestamp: true) + } catch { + self.error = error + } isRefreshing = false } } .onChange(of: scenePhase) { newPhase in guard newPhase == .active, - coreServices.playerManager.isPlaying + model.playerManager.isPlaying else { return } showPlayer = true @@ -281,8 +156,12 @@ struct RemoteItemListView: View { guard isFirstLoad else { return } isFirstLoad = false - Task { - await syncListContents(ignoreLastTimestamp: false) + Task { @MainActor in + do { + try await model.syncListContents(ignoreLastTimestamp: false) + } catch { + self.error = error + } } } } diff --git a/BookPlayerWatch/RemoteItemList/RemoteItemListViewModel.swift b/BookPlayerWatch/RemoteItemList/RemoteItemListViewModel.swift new file mode 100644 index 000000000..f4fd47ee8 --- /dev/null +++ b/BookPlayerWatch/RemoteItemList/RemoteItemListViewModel.swift @@ -0,0 +1,147 @@ +// +// RemoteItemListViewModel.swift +// BookPlayerWatch +// +// Created by Gianni Carlo on 28/1/25. +// Copyright © 2025 BookPlayer LLC. All rights reserved. +// + +import BookPlayerWatchKit +import Combine +import Foundation + +@MainActor +final class RemoteItemListViewModel: ObservableObject { + @Published var items: [SimpleLibraryItem] + @Published var lastPlayedItem: SimpleLibraryItem? + @Published var playingItemParentPath: String? + @Published var playerManager: PlayerManager + + let coreServices: CoreServices + let folderRelativePath: String? + private var disposeBag = Set() + + init( + coreServices: CoreServices, + folderRelativePath: String? = nil + ) { + self.coreServices = coreServices + self.playerManager = coreServices.playerManager + self.folderRelativePath = folderRelativePath + /// initial load of data + let fetchedItems = + coreServices.libraryService.fetchContents( + at: folderRelativePath, + limit: nil, + offset: nil + ) ?? [] + self._items = .init(initialValue: fetchedItems) + let lastItem = coreServices.libraryService.getLastPlayedItems(limit: 1)?.first + + if let lastItem { + self._lastPlayedItem = .init(initialValue: lastItem) + self._playingItemParentPath = .init( + initialValue: getPathForParentOfItem(currentPlayingPath: lastItem.relativePath) + ) + } else { + self._lastPlayedItem = .init(initialValue: nil) + self._playingItemParentPath = .init(initialValue: nil) + } + + self.bindCurrentItemObserver() + } + + func bindCurrentItemObserver() { + playerManager.currentItemPublisher() + .receive(on: DispatchQueue.main) + .sink { [weak self] currentItem in + guard let self else { return } + + if let currentItem, + let lastPlayedItem = self.coreServices.libraryService.getSimpleItem(with: currentItem.relativePath) { + self.lastPlayedItem = lastPlayedItem + } else { + self.lastPlayedItem = coreServices.libraryService.getLastPlayedItems(limit: 1)?.first + } + } + .store(in: &disposeBag) + } + + func getPathForParentOfItem(currentPlayingPath: String) -> String? { + let parentFolders: [String] = currentPlayingPath.allRanges(of: "/") + .map { String(currentPlayingPath.prefix(upTo: $0.lowerBound)) } + .reversed() + + guard let folderRelativePath = self.folderRelativePath else { + return parentFolders.last + } + + guard let index = parentFolders.firstIndex(of: folderRelativePath) else { + return nil + } + + let elementIndex = index - 1 + + guard elementIndex >= 0 else { + return nil + } + + return parentFolders[elementIndex] + } + + func syncListContents(ignoreLastTimestamp: Bool) async throws { + guard + await coreServices.syncService.canSyncListContents( + at: folderRelativePath, + ignoreLastTimestamp: ignoreLastTimestamp + ) + else { return } + + do { + try await coreServices.syncService.syncListContents(at: folderRelativePath) + } catch BPSyncError.reloadLastBook(let relativePath) { + try await reloadLastBook(relativePath: relativePath) + } catch BPSyncError.differentLastBook(let relativePath) { + try await setSyncedLastPlayedItem(relativePath: relativePath) + } catch { + throw error + } + + items = + coreServices.libraryService.fetchContents( + at: folderRelativePath, + limit: nil, + offset: nil + ) ?? [] + + if let lastPlayedItem { + playingItemParentPath = getPathForParentOfItem(currentPlayingPath: lastPlayedItem.relativePath) + } else { + playingItemParentPath = nil + } + } + + @MainActor + private func reloadLastBook(relativePath: String) async throws { + let wasPlaying = playerManager.isPlaying + playerManager.stop() + + try await coreServices.playerLoaderService.loadPlayer( + relativePath, + autoplay: wasPlaying + ) + } + + @MainActor + private func setSyncedLastPlayedItem(relativePath: String) async throws { + /// Only continue overriding local book if it's not currently playing + guard playerManager.isPlaying == false else { return } + + await coreServices.syncService.setLibraryLastBook(with: relativePath) + + try await coreServices.playerLoaderService.loadPlayer( + relativePath, + autoplay: false + ) + } +} diff --git a/BookPlayerWatch/RemotePlayerView.swift b/BookPlayerWatch/RemotePlayerView.swift index 36f1696d8..eae4bd1a7 100644 --- a/BookPlayerWatch/RemotePlayerView.swift +++ b/BookPlayerWatch/RemotePlayerView.swift @@ -11,6 +11,12 @@ import SwiftUI struct RemotePlayerView: View { @ObservedObject var playerManager: PlayerManager + let coreServices: CoreServices + + init(coreServices: CoreServices) { + self.coreServices = coreServices + self.playerManager = coreServices.playerManager + } var body: some View { VStack { @@ -34,7 +40,12 @@ struct RemotePlayerView: View { Spacer() - PlayerToolbarView(playerManager: playerManager) + PlayerToolbarView(coreServices: coreServices) + } + .background { + VolumeView(type: .local) + .accessibilityHidden(true) + .opacity(0) } .fixedSize(horizontal: false, vertical: false) .ignoresSafeArea(edges: .bottom) diff --git a/BookPlayerWatch/RootView.swift b/BookPlayerWatch/RootView.swift index a8c051d58..85e073c08 100644 --- a/BookPlayerWatch/RootView.swift +++ b/BookPlayerWatch/RootView.swift @@ -17,7 +17,7 @@ struct RootView: View { var body: some View { VStack { if coreServices.hasSyncEnabled { - RemoteItemListView(coreServices: coreServices) + RemoteItemListView(model: .init(coreServices: coreServices)) } else if contextManager.items.isEmpty && contextManager.isConnecting { ProgressView() } else { diff --git a/Shared/CoreData/Lightweight-Models/SimpleBookmark.swift b/Shared/CoreData/Lightweight-Models/SimpleBookmark.swift index 79eb43cfb..1cf6e9cb6 100644 --- a/Shared/CoreData/Lightweight-Models/SimpleBookmark.swift +++ b/Shared/CoreData/Lightweight-Models/SimpleBookmark.swift @@ -8,7 +8,10 @@ import Foundation -public struct SimpleBookmark: Decodable { +public struct SimpleBookmark: Decodable, Identifiable { + public var id: String { + return UUID().uuidString + } public let time: Double public let note: String? let type: BookmarkType