diff --git a/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj b/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj index 1a9ed49..b54ee28 100644 --- a/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj +++ b/ABS Client/Audiobookshelf swift.xcodeproj/project.pbxproj @@ -90,6 +90,7 @@ hasScannedForEncodings = 0; knownRegions = ( en, + de, Base, ); mainGroup = 39614D022FB4D44400DBEF5E; @@ -271,6 +272,7 @@ INFOPLIST_KEY_CFBundleName = "ABS Client"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -285,7 +287,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client"; PRODUCT_NAME = "ABS Client"; REGISTER_APP_GROUPS = YES; @@ -324,6 +326,7 @@ INFOPLIST_KEY_CFBundleName = "ABS Client"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -338,7 +341,7 @@ "@executable_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client"; PRODUCT_NAME = "ABS Client"; REGISTER_APP_GROUPS = YES; diff --git a/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift b/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift index 31d7728..4ccd0df 100644 --- a/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift +++ b/ABS Client/Audiobookshelf swift/Audiobookshelf_swiftApp.swift @@ -8,11 +8,24 @@ struct Audiobookshelf_swiftApp: App { @State private var appState = AppState() init() { + configureImageCache() #if os(iOS) configureAudioSession() #endif } + /// Increases the shared URLCache so AsyncImage covers persist across renders + /// and app launches. Default capacity is tiny (~4 MB / 20 MB). + private func configureImageCache() { + let memoryCapacity = 50 * 1024 * 1024 // 50 MB + let diskCapacity = 500 * 1024 * 1024 // 500 MB + URLCache.shared = URLCache( + memoryCapacity: memoryCapacity, + diskCapacity: diskCapacity, + diskPath: "covers" + ) + } + var body: some Scene { #if os(macOS) WindowGroup { diff --git a/ABS Client/Audiobookshelf swift/Models/APIResponses.swift b/ABS Client/Audiobookshelf swift/Models/APIResponses.swift index 43c4d08..1913a2a 100644 --- a/ABS Client/Audiobookshelf swift/Models/APIResponses.swift +++ b/ABS Client/Audiobookshelf swift/Models/APIResponses.swift @@ -31,11 +31,19 @@ struct LibraryItemDTO: Decodable { let media: MediaDTO? } +struct ChapterDTO: Decodable { + let id: Int? + let start: Double? + let end: Double? + let title: String? +} + struct MediaDTO: Decodable { let metadata: MetadataDTO? let duration: Double? let audioFiles: [AudioFileDTO]? let episodes: [EpisodeDTO]? + let chapters: [ChapterDTO]? } struct MetadataDTO: Decodable { @@ -69,6 +77,12 @@ struct AudioFileDTO: Decodable { } } +struct BookmarkDTO: Decodable { + let title: String? + let time: Double? + let createdAt: Double? +} + struct ProgressResponseDTO: Decodable { let id: String? let libraryItemId: String? @@ -77,6 +91,7 @@ struct ProgressResponseDTO: Decodable { let duration: Double? let isFinished: Bool? let lastUpdate: Double? + let bookmarks: [BookmarkDTO]? } struct MeResponseDTO: Decodable { diff --git a/ABS Client/Audiobookshelf swift/Models/Models.swift b/ABS Client/Audiobookshelf swift/Models/Models.swift index 236ec7a..7f9c93f 100644 --- a/ABS Client/Audiobookshelf swift/Models/Models.swift +++ b/ABS Client/Audiobookshelf swift/Models/Models.swift @@ -1,5 +1,12 @@ import Foundation +struct Chapter: Codable, Identifiable, Hashable { + let id: Int + let start: Double + let end: Double + let title: String +} + struct Library: Codable, Identifiable, Hashable { let id: String let name: String @@ -15,6 +22,7 @@ struct LibraryItem: Codable, Identifiable, Hashable { var mediaType: String = "book" var episodeId: String? = nil var description: String? = nil + var chapters: [Chapter] = [] var isPodcast: Bool { mediaType == "podcast" } var isPodcastContainer: Bool { isPodcast && episodeId == nil } diff --git a/ABS Client/Audiobookshelf swift/Services/ABSClient.swift b/ABS Client/Audiobookshelf swift/Services/ABSClient.swift index bdbebb3..2dfe588 100644 --- a/ABS Client/Audiobookshelf swift/Services/ABSClient.swift +++ b/ABS Client/Audiobookshelf swift/Services/ABSClient.swift @@ -106,6 +106,10 @@ final class ABSClient { ) item.mediaType = mediaType item.description = meta?.description + item.chapters = (raw.media?.chapters ?? []).compactMap { c in + guard let id = c.id, let start = c.start, let end = c.end, let title = c.title else { return nil } + return Chapter(id: id, start: start, end: end, title: title) + } return item } @@ -225,4 +229,43 @@ final class ABSClient { } var bearerHeader: [String: String] { ["Authorization": "Bearer \(auth.token)"] } + + // MARK: - Bookmarks + + func fetchBookmarks(itemId: String, episodeId: String? = nil) async throws -> [ServerBookmark] { + let req = try makeRequest(path: progressPath(itemId: itemId, episodeId: episodeId)) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { return [] } + if http.statusCode == 404 { return [] } + guard (200..<300).contains(http.statusCode) else { throw ABSClientError.httpStatus(http.statusCode) } + let dto = try JSONDecoder().decode(ProgressResponseDTO.self, from: data) + return (dto.bookmarks ?? []).compactMap { b in + guard let title = b.title, let time = b.time else { return nil } + return ServerBookmark(title: title, time: time, createdAt: b.createdAt) + } + } + + func createBookmark(itemId: String, time: Double, title: String) async throws { + let body = try JSONSerialization.data(withJSONObject: ["title": title, "time": time]) + let req = try makeRequest(path: "/api/me/item/\(itemId)/bookmark", method: "POST", body: body) + let (_, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw ABSClientError.httpStatus((response as? HTTPURLResponse)?.statusCode ?? 0) + } + } + + func deleteBookmark(itemId: String, time: Double) async throws { + let timeInt = Int(time) + let req = try makeRequest(path: "/api/me/item/\(itemId)/bookmark/\(timeInt)", method: "DELETE") + let (_, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw ABSClientError.httpStatus((response as? HTTPURLResponse)?.statusCode ?? 0) + } + } +} + +struct ServerBookmark { + let title: String + let time: Double + let createdAt: Double? } diff --git a/ABS Client/Audiobookshelf swift/Services/AppState.swift b/ABS Client/Audiobookshelf swift/Services/AppState.swift index cb0b748..9a2836a 100644 --- a/ABS Client/Audiobookshelf swift/Services/AppState.swift +++ b/ABS Client/Audiobookshelf swift/Services/AppState.swift @@ -10,9 +10,14 @@ final class AppState { let downloads: DownloadManager let sync: ProgressSyncManager let player: PlayerEngine + let history: HistoryManager + let bookmarks: BookmarkManager var currentItem: LibraryItem? var isPreparingPlayback: Bool = false + var language: String = UserDefaults.standard.string(forKey: "appLanguage") ?? "de" { + didSet { UserDefaults.standard.set(language, forKey: "appLanguage") } + } /// Map: PlaybackProgress.syncKey -> PlaybackProgress (server-known progress). /// Used to show progress bars on covers in the library views. @@ -20,6 +25,7 @@ final class AppState { private var syncTimer: Timer? private var lastReportedSecond: Double = -10 + private var lastTrackedChapterId: Int? init() { let auth = AuthStore() @@ -30,6 +36,8 @@ final class AppState { self.downloads = DownloadManager(client: client) self.sync = ProgressSyncManager(client: client) self.player = PlayerEngine() + self.history = HistoryManager() + self.bookmarks = BookmarkManager() } func bootstrap() async { @@ -86,27 +94,31 @@ final class AppState { return min(1, max(0, p.currentTime / p.duration)) } - func play(item: LibraryItem) async { + func play(item: LibraryItem, overrideStartAt: Double? = nil) async { if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady { - player.play() + if let pos = overrideStartAt { seekAbsolute(pos) } else { player.play() } return } + // Record position before switching to a new item + if let current = currentItem, player.absoluteCurrentTime > 5, + UserDefaults.standard.bool(forKey: "historyEnabled") { + history.record(item: current, position: player.absoluteCurrentTime, chapters: current.chapters) + } stopPlayback(reportFinal: true) isPreparingPlayback = true defer { isPreparingPlayback = false } var workItem = item - // Only fetch detail for books with empty audioFiles (podcast episodes - // arrive with their single audioFile already populated by the caller). - if !workItem.isPodcast && workItem.audioFiles.isEmpty && network.isOnline { + // Always fetch detail for books to get chapters; skip if downloaded offline. + if !workItem.isPodcast && network.isOnline { let alreadyDownloaded = downloads.isDownloaded(downloadKey: item.downloadKey) if !alreadyDownloaded, let detail = try? await client.fetchItemDetail(itemId: item.id) { workItem = detail } } - var startAt: Double = 0 - if network.isOnline { + var startAt: Double = overrideStartAt ?? 0 + if overrideStartAt == nil && network.isOnline { if let p = try? await client.fetchProgress(itemId: item.id, episodeId: workItem.episodeId) { // Replaying a finished item (or one with progress essentially at the end) // should start from the beginning, not drop the user at the last few seconds. @@ -116,15 +128,40 @@ final class AppState { } } } + if network.isOnline { + // Load bookmarks from server for this item + if let serverBMs = try? await client.fetchBookmarks(itemId: workItem.id, episodeId: workItem.episodeId) { + bookmarks.mergeFromServer(serverBMs, for: workItem) + } + } currentItem = workItem player.load(item: workItem, client: client, downloads: downloads, startAt: startAt) if player.errorMessage == nil { player.play() startSyncTimer() + let startingChapter = workItem.chapters.last { $0.start <= startAt } + lastTrackedChapterId = startingChapter?.id + if UserDefaults.standard.bool(forKey: "historyEnabled") { + history.record(item: workItem, position: startAt, chapters: workItem.chapters) + } } } + func playFromHistory(_ entry: HistoryEntry) async { + if let current = currentItem, + current.id == entry.itemId, + current.episodeId == entry.episodeId { + seekAbsolute(entry.position) + return + } + guard network.isOnline, + let detail = try? await client.fetchItemDetail(itemId: entry.itemId) else { return } + var item = detail + item.episodeId = entry.episodeId + await play(item: item, overrideStartAt: entry.position) + } + /// Convenience for podcast episodes. func play(podcast: LibraryItem, episode: PodcastEpisode) async { var synthetic = LibraryItem( @@ -146,6 +183,7 @@ final class AppState { player.teardown() currentItem = nil lastReportedSecond = -10 + lastTrackedChapterId = nil } func togglePlay() { @@ -161,7 +199,10 @@ final class AppState { } func seekAbsolute(_ target: Double) { - guard currentItem != nil else { return } + guard let item = currentItem else { return } + if UserDefaults.standard.bool(forKey: "historyEnabled") { + history.record(item: item, position: player.absoluteCurrentTime, chapters: item.chapters) + } player.seekAbsolute(target) reportProgress(force: true) } @@ -170,6 +211,22 @@ final class AppState { player.setRate(newRate) } + func addBookmark(title: String) { + guard let item = currentItem else { return } + let t = player.absoluteCurrentTime + bookmarks.add(item: item, time: t, title: title, chapters: item.chapters) + if network.isOnline { + Task { try? await client.createBookmark(itemId: item.id, time: t, title: title) } + } + } + + func deleteBookmark(_ bookmark: Bookmark) { + bookmarks.delete(bookmark) + if network.isOnline { + Task { try? await client.deleteBookmark(itemId: bookmark.itemId, time: bookmark.time) } + } + } + private func startSyncTimer() { syncTimer?.invalidate() let timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in @@ -181,10 +238,21 @@ final class AppState { syncTimer = timer } + private func detectChapterChange(item: LibraryItem) { + let currentId = player.currentChapter?.id + guard currentId != lastTrackedChapterId else { return } + defer { lastTrackedChapterId = currentId } + guard lastTrackedChapterId != nil, + let chapter = player.currentChapter, + UserDefaults.standard.bool(forKey: "historyEnabled") else { return } + history.record(item: item, position: chapter.start, chapters: item.chapters) + } + private func reportProgress(force: Bool) { guard let item = currentItem else { return } let t = player.absoluteCurrentTime let d = player.totalDuration + detectChapterChange(item: item) guard d > 0 else { return } if !force && abs(t - lastReportedSecond) < 3 { return } lastReportedSecond = t diff --git a/ABS Client/Audiobookshelf swift/Services/BookmarkManager.swift b/ABS Client/Audiobookshelf swift/Services/BookmarkManager.swift new file mode 100644 index 0000000..3f74a18 --- /dev/null +++ b/ABS Client/Audiobookshelf swift/Services/BookmarkManager.swift @@ -0,0 +1,86 @@ +import Foundation +import Observation + +struct Bookmark: Codable, Identifiable { + let id: UUID + let createdAt: Date + let itemId: String + let episodeId: String? + let itemTitle: String + var title: String + let chapterTitle: String? + let time: Double +} + +@Observable +@MainActor +final class BookmarkManager { + private(set) var bookmarks: [Bookmark] = [] + + private var saveFile: URL { + AppPaths.supportDirectory.appendingPathComponent("bookmarks.json") + } + + init() { load() } + + func add(item: LibraryItem, time: Double, title: String, chapters: [Chapter]) { + let chapter = chapters.last { $0.start <= time } + let bm = Bookmark( + id: UUID(), + createdAt: Date(), + itemId: item.id, + episodeId: item.episodeId, + itemTitle: item.title, + title: title, + chapterTitle: chapter?.title, + time: time + ) + bookmarks.insert(bm, at: 0) + save() + } + + func delete(_ bookmark: Bookmark) { + bookmarks.removeAll { $0.id == bookmark.id } + save() + } + + func bookmarks(for item: LibraryItem) -> [Bookmark] { + bookmarks.filter { $0.itemId == item.id && $0.episodeId == item.episodeId } + .sorted { $0.time < $1.time } + } + + /// Replaces bookmarks for an item with the server-loaded list (keeps bookmarks for other items). + func mergeFromServer(_ serverBookmarks: [ServerBookmark], for item: LibraryItem) { + bookmarks.removeAll { $0.itemId == item.id && $0.episodeId == item.episodeId } + let chapters = item.chapters + for sb in serverBookmarks { + let chapter = chapters.last { $0.start <= sb.time } + let bm = Bookmark( + id: UUID(), + createdAt: sb.createdAt.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date(), + itemId: item.id, + episodeId: item.episodeId, + itemTitle: item.title, + title: sb.title, + chapterTitle: chapter?.title, + time: sb.time + ) + bookmarks.append(bm) + } + bookmarks.sort { $0.createdAt > $1.createdAt } + save() + } + + private func save() { + try? FileManager.default.createDirectory( + at: AppPaths.supportDirectory, withIntermediateDirectories: true) + if let data = try? JSONEncoder().encode(bookmarks) { + try? data.write(to: saveFile) + } + } + + private func load() { + guard let data = try? Data(contentsOf: saveFile) else { return } + bookmarks = (try? JSONDecoder().decode([Bookmark].self, from: data)) ?? [] + } +} diff --git a/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift b/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift index de67f9c..d367b7e 100644 --- a/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift +++ b/ABS Client/Audiobookshelf swift/Services/DownloadManager.swift @@ -66,11 +66,16 @@ struct DownloadedItem: Codable, Hashable { @Observable @MainActor -final class DownloadManager { +final class DownloadManager: @unchecked Sendable { private let client: ABSClient /// Keyed by downloadKey (itemId or "itemId|episodeId"). private(set) var states: [String: DownloadState] = [:] private(set) var downloadedItems: [String: DownloadedItem] = [:] + /// Items currently being downloaded (removed on completion or cancellation). + private(set) var pendingItems: [String: LibraryItem] = [:] + /// Bytes received for the currently in-flight track per downloadKey. + /// Reset between tracks; cleared when the download finishes/cancels. + private(set) var inFlightBytes: [String: Int64] = [:] private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") } private var activeTasks: [String: Task] = [:] @@ -90,6 +95,36 @@ final class DownloadManager { return false } + func downloadedBytes(for downloadKey: String) -> Int64 { + if let item = downloadedItems[downloadKey] { + return item.tracks.reduce(Int64(0)) { sum, track in + let url = AppPaths.downloadsDirectory.appendingPathComponent(track.localPath) + return sum + fileSize(at: url) + } + } + if let pending = pendingItems[downloadKey] { + let onDisk = folderSize(at: AppPaths.downloadsDirectory.appendingPathComponent(pending.id)) + let inFlight = inFlightBytes[downloadKey] ?? 0 + return onDisk + inFlight + } + return 0 + } + + /// Called from the URL session delegate (any queue) to update in-flight bytes + /// for a currently downloading track. + nonisolated func _updateInFlightBytes(_ bytes: Int64, for key: String) { + Task { @MainActor [self] in + self.inFlightBytes[key] = bytes + } + } + + private func fileSize(at url: URL) -> Int64 { + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return 0 } + if let n = attrs[.size] as? NSNumber { return n.int64Value } + if let i = attrs[.size] as? Int { return Int64(i) } + return 0 + } + func localTrackURLs(for downloadKey: String) -> [URL]? { guard let item = downloadedItems[downloadKey] else { return nil } return item.tracks.map { AppPaths.downloadsDirectory.appendingPathComponent($0.localPath) } @@ -100,6 +135,7 @@ final class DownloadManager { let key = item.downloadKey guard activeTasks[key] == nil else { return } states[key] = .downloading(progress: 0) + pendingItems[key] = item let task = Task { @MainActor [weak self] in guard let self else { return } @@ -110,12 +146,14 @@ final class DownloadManager { workItem = try await self.client.fetchItemDetail(itemId: item.id) } catch { self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)") + self.pendingItems.removeValue(forKey: key) self.activeTasks[key] = nil return } } if workItem.audioFiles.isEmpty { self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.") + self.pendingItems.removeValue(forKey: key) self.activeTasks[key] = nil return } @@ -129,9 +167,12 @@ final class DownloadManager { activeTasks[downloadKey]?.cancel() activeTasks[downloadKey] = nil states[downloadKey] = .notDownloaded + pendingItems.removeValue(forKey: downloadKey) + inFlightBytes.removeValue(forKey: downloadKey) } func delete(downloadKey: String) { + pendingItems.removeValue(forKey: downloadKey) cancel(downloadKey: downloadKey) if let item = downloadedItems[downloadKey] { let dir = directoryURL(itemId: item.itemId, episodeId: item.episodeId) @@ -148,6 +189,18 @@ final class DownloadManager { persistIndex() } + private func folderSize(at url: URL) -> Int64 { + guard let enumerator = FileManager.default.enumerator( + atPath: url.path + ) else { return 0 } + var total: Int64 = 0 + for case let relPath as String in enumerator { + let fileURL = url.appendingPathComponent(relPath) + total += fileSize(at: fileURL) + } + return total + } + private func directoryURL(itemId: String, episodeId: String?) -> URL { var dir = AppPaths.downloadsDirectory.appendingPathComponent(itemId, isDirectory: true) if let episodeId { @@ -162,6 +215,10 @@ final class DownloadManager { } private func performDownload(workItem: LibraryItem, downloadKey: String) async { + defer { + pendingItems.removeValue(forKey: downloadKey) + inFlightBytes.removeValue(forKey: downloadKey) + } let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId) do { try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true) @@ -184,7 +241,7 @@ final class DownloadManager { let tempURL: URL do { - tempURL = try await downloadWithRetry(request: request, filename: file.filename) + tempURL = try await downloadWithRetry(request: request, filename: file.filename, downloadKey: downloadKey) } catch is CancellationError { states[downloadKey] = .notDownloaded return @@ -210,6 +267,9 @@ final class DownloadManager { durationSeconds: file.durationSeconds )) states[downloadKey] = .downloading(progress: Double(idx + 1) / Double(total)) + // Track is on disk now (folderSize picks it up). Clear the in-flight + // counter so the next track's bytes don't double-count. + inFlightBytes[downloadKey] = 0 } let downloaded = DownloadedItem( @@ -226,22 +286,22 @@ final class DownloadManager { } /// Downloads with up to `maxAttempts` retries and resume-data support so a brief - /// network dropout picks up where it left off. Uses the client session so that - /// self-signed server certificates are accepted. - private func downloadWithRetry(request: URLRequest, filename: String, maxAttempts: Int = 5) async throws -> URL { - let session = client.session + /// network dropout picks up where it left off. Uses a classic URLSessionDownloadTask + /// with explicit delegate (wrapped in a continuation) so we reliably get + /// `didWriteData` progress callbacks — the async `download(for:delegate:)` API + /// often doesn't fire them. + private func downloadWithRetry(request: URLRequest, filename: String, downloadKey: String, maxAttempts: Int = 5) async throws -> URL { var resumeData: Data? = nil var lastError: Error = URLError(.unknown) for attempt in 0.. (URL, URLResponse) { + let session = client.session + return try await withCheckedThrowingContinuation { continuation in + let delegate = DownloadProgressDelegate( + manager: self, + downloadKey: downloadKey, + continuation: continuation + ) + let task: URLSessionDownloadTask + if let resumeData { + task = session.downloadTask(withResumeData: resumeData) + } else { + task = session.downloadTask(with: request) + } + task.delegate = delegate + task.resume() + } + } + private func loadIndex() { guard let data = try? Data(contentsOf: indexFile), let decoded = try? JSONDecoder().decode([String: DownloadedItem].self, from: data) else { return } @@ -292,3 +378,78 @@ final class DownloadManager { } } } + +/// Per-task delegate for a single `URLSessionDownloadTask`. Forwards live byte +/// progress to the manager and bridges the delegate callbacks back to async/await +/// via a checked continuation. The system deletes the `didFinishDownloadingTo` +/// temp URL immediately after the callback returns, so we move it to a stable +/// location before resuming. +private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable { + private let manager: DownloadManager + private let downloadKey: String + private let continuation: CheckedContinuation<(URL, URLResponse), Error> + private var stableTempURL: URL? + private var didResume = false + private let lock = NSLock() + + init( + manager: DownloadManager, + downloadKey: String, + continuation: CheckedContinuation<(URL, URLResponse), Error> + ) { + self.manager = manager + self.downloadKey = downloadKey + self.continuation = continuation + super.init() + } + + private func resumeOnce(with result: Result<(URL, URLResponse), Error>) { + lock.lock(); defer { lock.unlock() } + guard !didResume else { return } + didResume = true + continuation.resume(with: result) + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + manager._updateInFlightBytes(totalBytesWritten, for: downloadKey) + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + // Move out of the system-temp folder before this delegate method returns + // (otherwise the OS deletes it). + let target = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".tmp") + do { + try FileManager.default.moveItem(at: location, to: target) + stableTempURL = target + } catch { + stableTempURL = nil + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + if let error { + resumeOnce(with: .failure(error)) + return + } + guard let url = stableTempURL, let response = task.response else { + resumeOnce(with: .failure(URLError(.cannotCreateFile))) + return + } + resumeOnce(with: .success((url, response))) + } +} diff --git a/ABS Client/Audiobookshelf swift/Services/HistoryManager.swift b/ABS Client/Audiobookshelf swift/Services/HistoryManager.swift new file mode 100644 index 0000000..714b47e --- /dev/null +++ b/ABS Client/Audiobookshelf swift/Services/HistoryManager.swift @@ -0,0 +1,98 @@ +import Foundation +import Observation + +struct HistoryEntry: Codable, Identifiable { + let id: UUID + let timestamp: Date + let itemId: String + let episodeId: String? + let itemTitle: String + let itemAuthor: String + let chapterTitle: String? + let position: Double +} + +@Observable +@MainActor +final class HistoryManager { + private(set) var entries: [HistoryEntry] = [] + + private static let maxEntries = 200 + private var saveFile: URL { + AppPaths.supportDirectory.appendingPathComponent("history.json") + } + + init() { load() } + + func record(item: LibraryItem, position: Double, chapters: [Chapter]) { + guard position >= 0 else { return } + if let last = entries.first, + last.itemId == item.id, + last.episodeId == item.episodeId, + abs(last.position - position) < 15 { return } + let chapter = chapters.last { $0.start <= position } + let entry = HistoryEntry( + id: UUID(), + timestamp: Date(), + itemId: item.id, + episodeId: item.episodeId, + itemTitle: item.title, + itemAuthor: item.author, + chapterTitle: chapter?.title, + position: position + ) + entries.insert(entry, at: 0) + if entries.count > Self.maxEntries { entries.removeLast() } + save() + } + + func clear() { + entries.removeAll() + save() + } + + func exportXML() -> URL? { + let formatter = ISO8601DateFormatter() + var xml = "\n" + xml += "\n" + for entry in entries { + xml += " \n" + xml += " \(formatter.string(from: entry.timestamp))\n" + xml += " \(xmlEscape(entry.itemTitle))\n" + xml += " \(xmlEscape(entry.itemAuthor))\n" + if let chapter = entry.chapterTitle { + xml += " \(xmlEscape(chapter))\n" + } + if let epId = entry.episodeId { + xml += " \(xmlEscape(epId))\n" + } + xml += " \(entry.position)\n" + xml += " \n" + } + xml += "\n" + + let tmpURL = FileManager.default.temporaryDirectory + .appendingPathComponent("hoerverlauf.xml") + try? xml.write(to: tmpURL, atomically: true, encoding: .utf8) + return tmpURL + } + + private func xmlEscape(_ s: String) -> String { + s.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + } + + private func save() { + try? FileManager.default.createDirectory( + at: AppPaths.supportDirectory, withIntermediateDirectories: true) + if let data = try? JSONEncoder().encode(entries) { + try? data.write(to: saveFile) + } + } + + private func load() { + guard let data = try? Data(contentsOf: saveFile) else { return } + entries = (try? JSONDecoder().decode([HistoryEntry].self, from: data)) ?? [] + } +} diff --git a/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift b/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift index f636d90..6657577 100644 --- a/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift +++ b/ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift @@ -15,6 +15,7 @@ enum SleepTimerMode: Equatable, Hashable { case off case minutes(Int) case endOfBook + case endOfChapter } @Observable @@ -29,8 +30,13 @@ final class PlayerEngine { var sleepTimer: SleepTimerMode = .off /// Verbleibende Wallclock-Sekunden bis der Sleep-Timer auslöst (0 wenn off). - /// Pausiert mit der Wiedergabe; bei `.endOfBook` rate-skaliert aus der Restspielzeit. + /// Pausiert mit der Wiedergabe; bei `.endOfBook`/`.endOfChapter` rate-skaliert aus der Restspielzeit. var sleepRemainingSeconds: Double = 0 + var chapters: [Chapter] = [] + + var currentChapter: Chapter? { + chapters.last { $0.start <= absoluteCurrentTime } + } private var player: AVQueuePlayer? private var trackDurations: [Double] = [] @@ -118,6 +124,7 @@ final class PlayerEngine { } } + self.chapters = item.chapters currentTitle = item.title currentAuthor = item.author currentCoverURL = client.coverURL(itemId: item.id) @@ -159,8 +166,10 @@ final class PlayerEngine { func setRate(_ newRate: Float) { rate = newRate if isPlaying { player?.rate = newRate } - if case .endOfBook = sleepTimer { - sleepRemainingSeconds = wallclockRemainingUntilEndOfBook() + switch sleepTimer { + case .endOfBook: sleepRemainingSeconds = wallclockRemainingUntilEndOfBook() + case .endOfChapter: sleepRemainingSeconds = wallclockRemainingUntilEndOfChapter() + default: break } updateNowPlayingInfo() } @@ -241,6 +250,7 @@ final class PlayerEngine { isPlaying = player.timeControlStatus == .playing if wasPlaying != isPlaying { updateNowPlayingInfo() } updateEndOfBookSleep() + updateEndOfChapterSleep() } func teardown() { @@ -264,6 +274,7 @@ final class PlayerEngine { itemId = nil errorMessage = nil isSeeking = false + chapters = [] currentTitle = "" currentAuthor = "" currentCoverURL = nil @@ -284,6 +295,8 @@ final class PlayerEngine { if isPlaying { startSleepTickTask() } case .endOfBook: sleepRemainingSeconds = wallclockRemainingUntilEndOfBook() + case .endOfChapter: + sleepRemainingSeconds = wallclockRemainingUntilEndOfChapter() } } @@ -322,6 +335,22 @@ final class PlayerEngine { return playback / r } + private func updateEndOfChapterSleep() { + guard case .endOfChapter = sleepTimer else { return } + sleepRemainingSeconds = wallclockRemainingUntilEndOfChapter() + if sleepRemainingSeconds <= 0 { + sleepTimer = .off + pause() + } + } + + private func wallclockRemainingUntilEndOfChapter() -> Double { + let chapterEnd = currentChapter?.end ?? totalDuration + let playback = max(0, chapterEnd - absoluteCurrentTime) + let r = max(0.1, Double(rate)) + return playback / r + } + // MARK: - Now-playing / remote commands private func configureRemoteCommandsIfNeeded() { diff --git a/ABS Client/Audiobookshelf swift/Views/ContentView.swift b/ABS Client/Audiobookshelf swift/Views/ContentView.swift index c25fb46..cda528f 100644 --- a/ABS Client/Audiobookshelf swift/Views/ContentView.swift +++ b/ABS Client/Audiobookshelf swift/Views/ContentView.swift @@ -29,6 +29,7 @@ struct ContentView: View { LoginView() } } + .environment(\.locale, Locale(identifier: app.language)) #if os(iOS) .frame(maxWidth: .infinity, maxHeight: .infinity) #else diff --git a/ABS Client/Audiobookshelf swift/Views/FullHistoryView.swift b/ABS Client/Audiobookshelf swift/Views/FullHistoryView.swift new file mode 100644 index 0000000..d829c09 --- /dev/null +++ b/ABS Client/Audiobookshelf swift/Views/FullHistoryView.swift @@ -0,0 +1,97 @@ +import SwiftUI + +struct FullHistoryView: View { + @Environment(AppState.self) private var app + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + ForEach(app.history.entries) { entry in + let isCurrent = entry.itemId == app.currentItem?.id && + entry.episodeId == app.currentItem?.episodeId + Button { + dismiss() + Task { await app.playFromHistory(entry) } + } label: { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text(entry.itemTitle) + .font(.subheadline.bold()) + HStack(spacing: 4) { + if let ch = entry.chapterTitle { + Text(ch).font(.caption).foregroundStyle(.secondary) + Text("·").font(.caption).foregroundStyle(.secondary) + } + Text(formatTime(entry.position)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + Text(relativeTime(entry.timestamp)) + .font(.caption2) + .foregroundStyle(.tertiary) + } + Spacer() + if !isCurrent { + Text(String(localized: "history.other_item")) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + if !app.history.entries.isEmpty { + Section { + Button(role: .destructive) { + app.history.clear() + } label: { + Text(String(localized: "history.clear")) + .frame(maxWidth: .infinity, alignment: .center) + } + } + } + } + .listStyle(.plain) + .navigationTitle(String(localized: "history.title")) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(String(localized: "settings.done")) { dismiss() } + } + } + #endif + .overlay { + if app.history.entries.isEmpty { + ContentUnavailableView( + String(localized: "history.empty"), + systemImage: "clock.arrow.circlepath", + description: Text(String(localized: "history.empty_desc")) + ) + } + } + } + #if os(iOS) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + #endif + } + + private func formatTime(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "0:00" } + let total = Int(seconds) + let h = total / 3600, m = (total % 3600) / 60, s = total % 60 + return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s) + } + + private func relativeTime(_ date: Date) -> String { + let diff = Int(-date.timeIntervalSinceNow) + if diff < 60 { return String(localized: "history.just_now") } + if diff < 3600 { return String(format: String(localized: "history.minutes_ago"), diff / 60) } + if diff < 86400 { return String(format: String(localized: "history.hours_ago"), diff / 3600) } + return String(format: String(localized: "history.days_ago"), diff / 86400) + } +} diff --git a/ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift b/ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift index 6a52659..b7aa33b 100644 --- a/ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift +++ b/ABS Client/Audiobookshelf swift/Views/LibraryGridView.swift @@ -3,6 +3,7 @@ import SwiftUI struct LibraryGridView: View { let items: [LibraryItem] var onRefresh: (() async -> Void)? = nil + var dimDownloading: Bool = false var onSelect: (LibraryItem) -> Void @AppStorage("libraryCoverSize") private var coverSize: Double = Self.defaultCoverSize @@ -22,7 +23,7 @@ struct LibraryGridView: View { ScrollView { LazyVGrid(columns: gridColumns, spacing: gridSpacing) { ForEach(items) { item in - LibraryItemCell(item: item) + LibraryItemCell(item: item, dimDownloading: dimDownloading) .onTapGesture { onSelect(item) } } } diff --git a/ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift b/ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift index 58cdbb6..582934b 100644 --- a/ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift +++ b/ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift @@ -3,13 +3,22 @@ import SwiftUI struct LibraryItemCell: View { @Environment(AppState.self) private var app let item: LibraryItem + var dimDownloading: Bool = false var body: some View { VStack(alignment: .leading, spacing: 2) { ZStack(alignment: .bottom) { ZStack(alignment: .topTrailing) { cover - downloadBadge.padding(4) + .opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0) + if !(dimDownloading && isActivelyDownloading) { + downloadBadge.padding(4) + } + } + .overlay { + if dimDownloading, case .downloading(let p) = app.downloads.state(for: item.downloadKey) { + LargeDownloadOverlay(progress: p) + } } CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId)) .padding(.horizontal, 3) @@ -23,16 +32,38 @@ struct LibraryItemCell: View { #endif .lineLimit(2) .multilineTextAlignment(.leading) - Text(item.author) - .font(.system(size: 9)) - .foregroundStyle(.secondary) - .lineLimit(1) + .opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0) + HStack(spacing: 4) { + Text(item.author) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + .lineLimit(1) + if dimDownloading { + let bytes = app.downloads.downloadedBytes(for: item.downloadKey) + if bytes > 0 { + Text("·") + .font(.system(size: 9)) + .foregroundStyle(.secondary) + Text(formatBytes(bytes)) + .font(.system(size: 9, weight: .medium).monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() + } + } + } + .opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0) } // Ensure the cell fills its full grid column width .frame(maxWidth: .infinity, alignment: .leading) .contextMenu { downloadMenuItems } } + private var isActivelyDownloading: Bool { + if case .downloading = app.downloads.state(for: item.downloadKey) { return true } + return false + } + // MARK: - Cover private var cover: some View { @@ -198,3 +229,40 @@ struct DownloadProgressRing: View { .shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 1) } } + +struct LargeDownloadOverlay: View { + let progress: Double + var size: CGFloat = 64 + + private var lineWidth: CGFloat { size / 13 } + private var padding: CGFloat { size / 7 } + private var fontSize: CGFloat { max(9, size / 5) } + + var body: some View { + ZStack { + Circle() + .fill(Color.black.opacity(0.65)) + Circle() + .stroke(Color.white.opacity(0.2), lineWidth: lineWidth) + .padding(padding) + Circle() + .trim(from: 0, to: max(0.03, min(progress, 1))) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .padding(padding) + .animation(.easeInOut(duration: 0.3), value: progress) + Text("\(Int(progress * 100))%") + .font(.system(size: fontSize, weight: .semibold).monospacedDigit()) + .foregroundStyle(.white) + } + .frame(width: size, height: size) + .shadow(color: .black.opacity(0.45), radius: 6) + } +} + +func formatBytes(_ bytes: Int64) -> String { + let mb = Double(bytes) / 1_048_576 + if mb >= 1024 { return String(format: "%.1f GB", mb / 1024) } + if mb >= 1 { return String(format: "%.0f MB", mb) } + return String(format: "%.0f KB", Double(bytes) / 1024) +} diff --git a/ABS Client/Audiobookshelf swift/Views/LibraryListView.swift b/ABS Client/Audiobookshelf swift/Views/LibraryListView.swift index 5f83d4f..beb25af 100644 --- a/ABS Client/Audiobookshelf swift/Views/LibraryListView.swift +++ b/ABS Client/Audiobookshelf swift/Views/LibraryListView.swift @@ -12,13 +12,14 @@ enum LibraryLayout: String, CaseIterable, Identifiable { struct LibraryListView: View { let items: [LibraryItem] var onRefresh: (() async -> Void)? = nil + var dimDownloading: Bool = false let onSelect: (LibraryItem) -> Void var body: some View { #if os(iOS) List { ForEach(items) { item in - LibraryListRow(item: item) + LibraryListRow(item: item, dimDownloading: dimDownloading) .contentShape(Rectangle()) .onTapGesture { onSelect(item) } .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) @@ -32,7 +33,7 @@ struct LibraryListView: View { ScrollView { LazyVStack(spacing: 0) { ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in - LibraryListRow(item: item) + LibraryListRow(item: item, dimDownloading: dimDownloading) .contentShape(Rectangle()) .onTapGesture { onSelect(item) } if idx < items.count - 1 { @@ -49,10 +50,17 @@ struct LibraryListView: View { struct LibraryListRow: View { @Environment(AppState.self) private var app let item: LibraryItem + var dimDownloading: Bool = false var body: some View { HStack(spacing: 12) { - cover + ZStack { + cover + .opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0) + if dimDownloading, case .downloading(let p) = app.downloads.state(for: item.downloadKey) { + LargeDownloadOverlay(progress: p, size: 40) + } + } VStack(alignment: .leading, spacing: 2) { Text(item.title) .font(.headline) @@ -77,18 +85,27 @@ struct LibraryListRow: View { #endif } } + .opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0) Spacer(minLength: 8) #if os(macOS) - if item.durationSeconds > 0 { + if dimDownloading, app.downloads.downloadedBytes(for: item.downloadKey) > 0 { + Text(formatBytes(app.downloads.downloadedBytes(for: item.downloadKey))) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0) + } else if item.durationSeconds > 0 { Text(formatDuration(item.durationSeconds)) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) + .opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0) } #endif - downloadStatus - #if os(macOS) - .frame(width: 28) - #endif + if !(dimDownloading && isActivelyDownloading) { + downloadStatus + #if os(macOS) + .frame(width: 28) + #endif + } } #if os(macOS) .padding(.horizontal, 16) @@ -97,6 +114,11 @@ struct LibraryListRow: View { .contextMenu { downloadMenuItems } } + private var isActivelyDownloading: Bool { + if case .downloading = app.downloads.state(for: item.downloadKey) { return true } + return false + } + private var cover: some View { Group { if let url = app.client.coverURL(itemId: item.id) { diff --git a/ABS Client/Audiobookshelf swift/Views/MainView.swift b/ABS Client/Audiobookshelf swift/Views/MainView.swift index c64201b..a8be7cf 100644 --- a/ABS Client/Audiobookshelf swift/Views/MainView.swift +++ b/ABS Client/Audiobookshelf swift/Views/MainView.swift @@ -3,6 +3,7 @@ import SwiftUI enum LibraryFilter: Hashable { case library(String) case downloaded + case history } @Observable @@ -15,8 +16,6 @@ final class LibraryViewModel { var selection: LibraryFilter? func loadLibraries(client: ABSClient) async { - isLoading = true - defer { isLoading = false } do { libraries = try await client.fetchLibraries() if selection == nil, let first = libraries.first { @@ -29,8 +28,6 @@ final class LibraryViewModel { func loadItems(client: ABSClient, downloads: DownloadManager) async { guard let selection else { return } - isLoading = true - defer { isLoading = false } switch selection { case .library(let id): do { @@ -39,8 +36,11 @@ final class LibraryViewModel { } catch { errorMessage = error.localizedDescription } + case .history: + items = [] + errorMessage = nil case .downloaded: - items = downloads.downloadedItems.values.map { di in + let completed = downloads.downloadedItems.values.map { di -> LibraryItem in let files: [AudioFile] = di.tracks.enumerated().map { idx, t in AudioFile( ino: t.ino, @@ -62,7 +62,12 @@ final class LibraryViewModel { li.episodeId = episodeId } return li - }.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } + } + let inProgress = downloads.pendingItems.values.filter { + downloads.downloadedItems[$0.downloadKey] == nil + } + items = (completed + Array(inProgress)) + .sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } errorMessage = nil } } @@ -73,6 +78,8 @@ struct MainView: View { @State private var vm = LibraryViewModel() @State private var navPath: [LibraryItem] = [] @AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue + @AppStorage("historyEnabled") private var historyEnabled: Bool = false + @State private var showFullHistory: Bool = false #if os(iOS) @State private var showSettings: Bool = false #endif @@ -90,11 +97,25 @@ struct MainView: View { navPath.removeAll() Task { await loadAll() } } + .onChange(of: app.downloads.pendingItems.count) { _, _ in + if vm.selection == .downloaded { + Task { await vm.loadItems(client: app.client, downloads: app.downloads) } + } + } + .onChange(of: app.downloads.downloadedItems.count) { _, _ in + if vm.selection == .downloaded { + Task { await vm.loadItems(client: app.client, downloads: app.downloads) } + } + } .safeAreaInset(edge: .bottom, spacing: 0) { PlayerBar() .animation(.easeInOut(duration: 0.2), value: app.currentItem?.id) .animation(.easeInOut(duration: 0.2), value: app.isPreparingPlayback) } + .sheet(isPresented: $showFullHistory) { + FullHistoryView() + .environment(app) + } } @ViewBuilder @@ -115,6 +136,14 @@ struct MainView: View { Label(l.label, systemImage: l.systemImage).tag(l.rawValue) } } + if historyEnabled { + Divider() + Button { + showFullHistory = true + } label: { + Label(String(localized: "player.history_all"), systemImage: "clock.arrow.circlepath") + } + } Divider() Button { showSettings = true @@ -151,8 +180,14 @@ struct MainView: View { } private func loadAll() async { + vm.items = [] + vm.isLoading = true + defer { vm.isLoading = false } + await vm.loadLibraries(client: app.client) - await vm.loadItems(client: app.client, downloads: app.downloads) + if vm.selection != .history { + await vm.loadItems(client: app.client, downloads: app.downloads) + } await app.refreshProgressCache() } @@ -169,106 +204,232 @@ struct MainView: View { #if os(macOS) private var sidebar: some View { List(selection: $vm.selection) { - Section("Bibliotheken") { + Section(String(localized: "sidebar.libraries")) { ForEach(vm.libraries) { lib in Label(lib.name, systemImage: "books.vertical") .tag(LibraryFilter.library(lib.id)) } } - Section("Offline") { - Label("Heruntergeladen", systemImage: "arrow.down.circle.fill") + Section(String(localized: "sidebar.offline")) { + Label(String(localized: "nav.downloaded"), systemImage: "arrow.down.circle.fill") .tag(LibraryFilter.downloaded) } + if historyEnabled { + Section(String(localized: "nav.history")) { + Label(String(localized: "sidebar.history"), systemImage: "clock.arrow.circlepath") + .tag(LibraryFilter.history) + } + } } .listStyle(.sidebar) - .navigationTitle("ABS Client") + .navigationTitle(String(localized: "sidebar.app_title")) .safeAreaInset(edge: .bottom) { sidebarFooter } } private var sidebarFooter: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(spacing: 0) { Divider() - HStack(spacing: 8) { - Circle() - .fill(app.network.isOnline ? .green : .orange) - .frame(width: 8, height: 8) - Text(app.network.isOnline ? "Online" : "Offline") - .font(.caption) - if app.sync.queuedCount > 0 { - Text("(\(app.sync.queuedCount) wartend)") - .font(.caption) + VStack(spacing: 0) { + HStack(spacing: 6) { + Circle() + .fill(app.network.isOnline ? Color.green : Color.orange) + .frame(width: 6, height: 6) + Text(app.network.isOnline + ? String(localized: "sidebar.status_online") + : String(localized: "sidebar.status_offline")) + .font(.caption2) + .foregroundStyle(.tertiary) + if app.sync.queuedCount > 0 { + Text("· \(app.sync.queuedCount)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + Spacer() + } + .padding(.bottom, 4) + HStack(spacing: 6) { + Image(systemName: "person.circle") + .font(.caption2) .foregroundStyle(.secondary) + Text(app.auth.username) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + Spacer() + Button { + app.stopPlayback() + app.auth.logout() + } label: { + Image(systemName: "rectangle.portrait.and.arrow.right") + .font(.caption2) + .foregroundStyle(.secondary) + } + .buttonStyle(.borderless) + .help(String(localized: "settings.logout")) } - Spacer() } - HStack { - Text(app.auth.username).font(.caption).foregroundStyle(.secondary) - Spacer() - Button("Abmelden") { - app.stopPlayback() - app.auth.logout() - } - .buttonStyle(.borderless) - .font(.caption) + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 10) + // Reserve space for PlayerBar — macOS safeAreaInset doesn't propagate into + // nested safeAreaInset overlays, so we add explicit spacing here. + if app.currentItem != nil || app.isPreparingPlayback { + Color.clear.frame(height: 78) } } - .padding(.horizontal, 12) - .padding(.vertical, 8) + } + + private var historyDetailContent: some View { + List { + ForEach(app.history.entries) { entry in + let isCurrent = entry.itemId == app.currentItem?.id && + entry.episodeId == app.currentItem?.episodeId + Button { + Task { await app.playFromHistory(entry) } + } label: { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text(entry.itemTitle) + .font(.subheadline.bold()) + HStack(spacing: 4) { + if let ch = entry.chapterTitle { + Text(ch).font(.caption).foregroundStyle(.secondary) + Text("·").font(.caption).foregroundStyle(.secondary) + } + Text(historyFormatTime(entry.position)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + Text(historyRelativeTime(entry.timestamp)) + .font(.caption2).foregroundStyle(.tertiary) + } + Spacer() + if !isCurrent { + Text(String(localized: "history.other_item")) + .font(.caption2).foregroundStyle(.tertiary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + if !app.history.entries.isEmpty { + Section { + Button(role: .destructive) { + app.history.clear() + } label: { + Text(String(localized: "history.clear")) + .frame(maxWidth: .infinity, alignment: .center) + } + } + } + } + .listStyle(.plain) + .overlay { + if app.history.entries.isEmpty { + ContentUnavailableView( + String(localized: "history.empty"), + systemImage: "clock.arrow.circlepath", + description: Text(String(localized: "history.empty_desc")) + ) + } + } + } + + private func historyFormatTime(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "0:00" } + let total = Int(seconds) + let h = total / 3600, m = (total % 3600) / 60, s = total % 60 + return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s) + } + + private func historyRelativeTime(_ date: Date) -> String { + let diff = Int(-date.timeIntervalSinceNow) + if diff < 60 { return String(localized: "history.just_now") } + if diff < 3600 { return String(format: String(localized: "history.minutes_ago"), diff / 60) } + if diff < 86400 { return String(format: String(localized: "history.hours_ago"), diff / 3600) } + return String(format: String(localized: "history.days_ago"), diff / 86400) } #endif - // MARK: - Detail content (shared) + // MARK: - Detail content @ViewBuilder private var detail: some View { - if vm.isLoading && vm.items.isEmpty { - ProgressView("Lade Bibliothek …") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let err = vm.errorMessage, vm.items.isEmpty { - ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err)) - } else if vm.items.isEmpty { - ContentUnavailableView("Keine Hörbücher", systemImage: "books.vertical", description: Text("Diese Auswahl enthält noch keine Hörbücher.")) + #if os(macOS) + if vm.selection == .history { + historyDetailContent + .navigationTitle(String(localized: "sidebar.history")) } else { - Group { - switch layout { - case .grid: - LibraryGridView(items: vm.items, onRefresh: loadAll) { handleSelect($0) } - case .list: - LibraryListView(items: vm.items, onRefresh: loadAll) { handleSelect($0) } - } - } - #if os(macOS) - .navigationTitle(currentTitle) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - Task { await loadAll() } - } label: { - if vm.isLoading { - ProgressView().controlSize(.small) - } else { - Image(systemName: "arrow.clockwise") - } - } - .help("Bibliothek, Cover und Hörfortschritte neu laden") - .disabled(vm.isLoading) - } - ToolbarItem(placement: .primaryAction) { - Picker("Ansicht", selection: $layoutRaw) { - ForEach(LibraryLayout.allCases) { l in - Image(systemName: l.systemImage) - .help(l.label) - .tag(l.rawValue) - } - } - .pickerStyle(.segmented) - .help("Zwischen Kachel- und Listenansicht wechseln") - } - } - #endif + libraryContent } + #else + libraryContent + #endif + } + + private var libraryContent: some View { + ZStack { + if vm.isLoading && vm.items.isEmpty { + ProgressView("Lade Bibliothek …") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity) + } else if let err = vm.errorMessage, vm.items.isEmpty { + ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err)) + .transition(.opacity) + } else if vm.items.isEmpty { + ContentUnavailableView("Keine Hörbücher", systemImage: "books.vertical", description: Text("Diese Auswahl enthält noch keine Hörbücher.")) + .transition(.opacity) + } else { + libraryGridOrList.transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.2), value: vm.isLoading) + .animation(.easeInOut(duration: 0.2), value: vm.items.isEmpty) + } + + @ViewBuilder + private var libraryGridOrList: some View { + Group { + let isDownloaded = vm.selection == .downloaded + switch layout { + case .grid: + LibraryGridView(items: vm.items, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) } + case .list: + LibraryListView(items: vm.items, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) } + } + } + #if os(macOS) + .navigationTitle(currentTitle) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { await loadAll() } + } label: { + if vm.isLoading { + ProgressView().controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } + } + .help("Bibliothek, Cover und Hörfortschritte neu laden") + .disabled(vm.isLoading) + } + ToolbarItem(placement: .primaryAction) { + Picker("Ansicht", selection: $layoutRaw) { + ForEach(LibraryLayout.allCases) { l in + Image(systemName: l.systemImage) + .help(l.label) + .tag(l.rawValue) + } + } + .pickerStyle(.segmented) + .help("Zwischen Kachel- und Listenansicht wechseln") + } + } + #endif } // MARK: - iOS-only helpers @@ -319,7 +480,8 @@ struct MainView: View { private var selectionIcon: String { switch vm.selection { case .downloaded: return "arrow.down.circle.fill" - default: return "books.vertical" + case .history: return "clock.arrow.circlepath" + default: return "books.vertical" } } #endif @@ -332,6 +494,8 @@ struct MainView: View { return vm.libraries.first(where: { $0.id == id })?.name ?? "Bibliothek" case .downloaded: return "Heruntergeladen" + case .history: + return String(localized: "sidebar.history") case .none: return "Bibliothek" } diff --git a/ABS Client/Audiobookshelf swift/Views/PlaybackDetailsView.swift b/ABS Client/Audiobookshelf swift/Views/PlaybackDetailsView.swift new file mode 100644 index 0000000..41a7f1b --- /dev/null +++ b/ABS Client/Audiobookshelf swift/Views/PlaybackDetailsView.swift @@ -0,0 +1,245 @@ +import SwiftUI + +struct PlaybackDetailsView: View { + @Environment(AppState.self) private var app + enum Tab: String, CaseIterable { + case chapters = "Kapitel" + case bookmarks = "Lesezeichen" + } + + @State private var selectedTab: Tab = .chapters + @State private var showAddBookmark: Bool = false + @State private var newBookmarkTitle: String = "" + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + Picker(selection: $selectedTab) { + ForEach(Tab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } label: { EmptyView() } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal) + .padding(.top, 8) + .padding(.bottom, 4) + + Divider() + + switch selectedTab { + case .chapters: chaptersTab + case .bookmarks: bookmarksTab + } + } + .navigationTitle(selectedTab.rawValue) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + } + .alert("Lesezeichen hinzufügen", isPresented: $showAddBookmark) { + TextField("Name", text: $newBookmarkTitle) + Button("Hinzufügen") { + let title = newBookmarkTitle.trimmingCharacters(in: .whitespaces) + app.addBookmark(title: title.isEmpty ? defaultBookmarkName : title) + newBookmarkTitle = "" + } + Button("Abbrechen", role: .cancel) { newBookmarkTitle = "" } + } message: { + Text("Gib einen Namen für dieses Lesezeichen ein.") + } + #if os(iOS) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + #else + .frame(minWidth: 420, minHeight: 520) + #endif + } + + // MARK: - Chapters tab + + private var chaptersTab: some View { + let chapters = app.currentItem?.chapters ?? [] + let current = app.player.currentChapter + return ScrollViewReader { proxy in + List { + if !chapters.isEmpty { + Section { + chapterNavigationBar + } + } + ForEach(chapters) { chapter in + Button { + app.seekAbsolute(chapter.start) + } label: { + HStack(spacing: 10) { + if chapter.id == current?.id { + Image(systemName: "play.fill") + .font(.caption) + .foregroundStyle(.tint) + .frame(width: 14) + } else { + Spacer().frame(width: 14) + } + VStack(alignment: .leading, spacing: 2) { + Text(chapter.title) + .font(chapter.id == current?.id ? .body.bold() : .body) + .foregroundStyle(chapter.id == current?.id ? Color.accentColor : Color.primary) + Text(formatTime(chapter.start)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + Spacer() + Text(formatDuration(chapter.end - chapter.start)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .id(chapter.id) + } + } + .listStyle(.plain) + .onAppear { + guard let id = current?.id else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + withAnimation(.easeInOut(duration: 0.4)) { + proxy.scrollTo(id, anchor: .center) + } + } + } + } + } + + private var chapterNavigationBar: some View { + let chapters = app.currentItem?.chapters ?? [] + let current = app.player.currentChapter + let currentIdx = chapters.firstIndex { $0.id == current?.id } + let hasPrev = (currentIdx ?? 0) > 0 + let hasNext = (currentIdx.map { $0 < chapters.count - 1 }) ?? false + + return HStack(spacing: 0) { + Spacer() + navButton(systemImage: "chevron.backward.to.line", + help: "Kapitelanfang", + disabled: current == nil) { + if let ch = current { app.seekAbsolute(ch.start) } + } + Spacer() + navButton(systemImage: "chevron.backward", + help: "Vorheriges Kapitel", + disabled: !hasPrev) { + if let idx = currentIdx { app.seekAbsolute(chapters[idx - 1].start) } + } + Spacer() + navButton(systemImage: "chevron.forward", + help: "Nächstes Kapitel", + disabled: !hasNext) { + if let idx = currentIdx { app.seekAbsolute(chapters[idx + 1].start) } + } + Spacer() + navButton(systemImage: "chevron.forward.to.line", + help: "Kapitelende", + disabled: current == nil) { + if let ch = current { app.seekAbsolute(max(ch.start, ch.end - 0.5)) } + } + Spacer() + } + .padding(.vertical, 4) + } + + private func navButton(systemImage: String, help: String, disabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.title3) + .frame(minWidth: 44, minHeight: 36) + } + .buttonStyle(.plain) + .disabled(disabled) + .foregroundStyle(disabled ? Color.secondary : Color.accentColor) + .help(help) + } + + // MARK: - Bookmarks tab + + private var bookmarksTab: some View { + let item = app.currentItem + let itemBookmarks = item.map { app.bookmarks.bookmarks(for: $0) } ?? [] + return List { + Section { + Button { + newBookmarkTitle = defaultBookmarkName + showAddBookmark = true + } label: { + Label("Lesezeichen hinzufügen", systemImage: "bookmark.fill") + } + .disabled(item == nil || !app.player.isReady) + } + + ForEach(itemBookmarks) { bm in + Button { + app.seekAbsolute(bm.time) + } label: { + VStack(alignment: .leading, spacing: 3) { + Text(bm.title) + .font(.subheadline.bold()) + HStack(spacing: 4) { + if let ch = bm.chapterTitle { + Text(ch).font(.caption).foregroundStyle(.secondary) + Text("·").font(.caption).foregroundStyle(.secondary) + } + Text(formatTime(bm.time)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .onDelete { offsets in + for idx in offsets { + app.deleteBookmark(itemBookmarks[idx]) + } + } + } + .listStyle(.plain) + .overlay { + if itemBookmarks.isEmpty { + ContentUnavailableView( + "Keine Lesezeichen", + systemImage: "bookmark", + description: Text(String(localized: "details.no_bookmarks_desc")) + ) + } + } + } + + // MARK: - Helpers + + private var defaultBookmarkName: String { + let t = app.player.absoluteCurrentTime + if let ch = app.player.currentChapter { + return "\(ch.title) · \(formatTime(t))" + } + return formatTime(t) + } + + private func formatTime(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "0:00" } + let total = Int(seconds) + let h = total / 3600, m = (total % 3600) / 60, s = total % 60 + return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s) + } + + private func formatDuration(_ seconds: Double) -> String { + guard seconds.isFinite, seconds > 0 else { return "" } + let total = Int(seconds) + let h = total / 3600, m = (total % 3600) / 60, s = total % 60 + if h > 0 { return String(format: "%dh %02dm", h, m) } + if m > 0 { return String(format: "%dm %02ds", m, s) } + return String(format: "%ds", s) + } + +} diff --git a/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift b/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift index 5619672..c65b0bf 100644 --- a/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift +++ b/ABS Client/Audiobookshelf swift/Views/PlayerBar.swift @@ -3,8 +3,11 @@ import SwiftUI struct PlayerBar: View { @Environment(AppState.self) private var app @AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30 + @AppStorage("historyEnabled") private var historyEnabled: Bool = false @State private var scrubbing: Bool = false @State private var scrubValue: Double = 0 + @State private var showDetails: Bool = false + @State private var showFullHistory: Bool = false var body: some View { if let item = app.currentItem { @@ -21,6 +24,14 @@ struct PlayerBar: View { .background(.bar) } .transition(.move(edge: .bottom).combined(with: .opacity)) + .sheet(isPresented: $showDetails) { + PlaybackDetailsView() + .environment(app) + } + .sheet(isPresented: $showFullHistory) { + FullHistoryView() + .environment(app) + } } else if app.isPreparingPlayback { VStack(spacing: 0) { Divider() @@ -77,6 +88,17 @@ struct PlayerBar: View { Spacer() + if historyEnabled { + historyQuickMenu + } + + Button { showDetails = true } label: { + Image(systemName: "list.bullet.indent") + .font(.system(size: 22)) + } + .buttonStyle(.plain) + .disabled(!app.player.isReady) + sleepMenu rateMenu @@ -119,6 +141,17 @@ struct PlayerBar: View { Spacer(minLength: 0) + if historyEnabled { + historyQuickMenu + } + + Button { showDetails = true } label: { + Image(systemName: "list.bullet.indent") + } + .buttonStyle(.plain) + .disabled(!app.player.isReady) + .help("Kapitel & Lesezeichen") + statusIndicator Button { @@ -235,6 +268,51 @@ struct PlayerBar: View { } } + private var detailsButtonVisible: Bool { + app.player.isReady + } + + private var historyQuickMenu: some View { + Menu { + let recent = Array(app.history.entries + .filter { $0.itemId == app.currentItem?.id && $0.episodeId == app.currentItem?.episodeId } + .prefix(5)) + if recent.isEmpty { + Text(String(localized: "history.empty")) + .foregroundStyle(.secondary) + } else { + ForEach(recent) { entry in + Button { + app.seekAbsolute(entry.position) + } label: { + let timeStr = formatTime(entry.position) + let label = entry.chapterTitle.map { "\($0) · \(timeStr)" } ?? timeStr + Label(label, systemImage: "clock") + } + } + Divider() + } + Button { + showFullHistory = true + } label: { + Label(String(localized: "player.history_all"), systemImage: "clock.arrow.circlepath") + } + } label: { + Image(systemName: "clock.arrow.circlepath") + #if os(iOS) + .font(.system(size: 22)) + #else + .font(.system(size: 16)) + #endif + } + #if os(macOS) + .menuStyle(.borderlessButton) + .fixedSize() + #endif + .menuIndicator(.hidden) + .help(String(localized: "player.history_recent")) + } + private var sleepMenu: some View { Menu { sleepOption(title: "Aus", mode: .off) @@ -244,6 +322,9 @@ struct PlayerBar: View { sleepOption(title: "30 Minuten", mode: .minutes(30)) sleepOption(title: "1 Stunde", mode: .minutes(60)) sleepOption(title: endOfPlaybackLabel, mode: .endOfBook) + if !(app.currentItem?.chapters.isEmpty ?? true) { + sleepOption(title: "Bis Ende des Kapitels", mode: .endOfChapter) + } } label: { Image(systemName: app.player.sleepTimer == .off ? "moon.zzz" : "moon.zzz.fill") #if os(iOS) diff --git a/ABS Client/Audiobookshelf swift/Views/SettingsView.swift b/ABS Client/Audiobookshelf swift/Views/SettingsView.swift index ca83838..ef258af 100644 --- a/ABS Client/Audiobookshelf swift/Views/SettingsView.swift +++ b/ABS Client/Audiobookshelf swift/Views/SettingsView.swift @@ -9,8 +9,11 @@ struct SettingsView: View { @AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30 @AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue @AppStorage("autoRefreshOnLaunch") private var autoRefreshOnLaunch: Bool = true + @AppStorage("historyEnabled") private var historyEnabled: Bool = false @State private var showLogoutConfirm: Bool = false + @State private var showHistoryDisableConfirm: Bool = false + @State private var showHistoryExport: Bool = false private static let skipOptions: [Int] = [10, 15, 30, 45, 60, 90] @@ -20,6 +23,7 @@ struct SettingsView: View { Form { connectionSection playbackSection + historySection appearanceSection downloadsSection aboutSection @@ -45,6 +49,24 @@ struct SettingsView: View { } message: { Text("Du wirst zurück zur Login-Maske geschickt. Heruntergeladene Inhalte bleiben.") } + .confirmationDialog( + "Hörverlauf deaktivieren?", + isPresented: $showHistoryDisableConfirm, + titleVisibility: .visible + ) { + Button("Verlauf löschen & deaktivieren", role: .destructive) { + app.history.clear() + historyEnabled = false + } + Button("Abbrechen", role: .cancel) { } + } message: { + Text("Der gesamte aufgezeichnete Hörverlauf wird unwiderruflich gelöscht.") + } + .sheet(isPresented: $showHistoryExport) { + if let url = app.history.exportXML() { + ShareSheet(url: url) + } + } } #else TabView { @@ -54,6 +76,9 @@ struct SettingsView: View { playbackPane .tabItem { Label("Wiedergabe", systemImage: "play.circle") } + historyPane + .tabItem { Label("Verlauf", systemImage: "clock.arrow.circlepath") } + appearancePane .tabItem { Label("Darstellung", systemImage: "square.grid.2x2") } @@ -61,7 +86,7 @@ struct SettingsView: View { .tabItem { Label("Über", systemImage: "info.circle") } } .padding(20) - .frame(width: 480, height: 320) + .frame(width: 480, height: 360) .confirmationDialog( "Mit Server abmelden?", isPresented: $showLogoutConfirm, @@ -75,6 +100,19 @@ struct SettingsView: View { } message: { Text("Du wirst zur Login-Maske zurückgesetzt. Heruntergeladene Hörbücher bleiben erhalten.") } + .confirmationDialog( + "Hörverlauf deaktivieren?", + isPresented: $showHistoryDisableConfirm, + titleVisibility: .visible + ) { + Button("Verlauf löschen & deaktivieren", role: .destructive) { + app.history.clear() + historyEnabled = false + } + Button("Abbrechen", role: .cancel) { } + } message: { + Text("Der gesamte aufgezeichnete Hörverlauf wird unwiderruflich gelöscht.") + } #endif } @@ -130,6 +168,41 @@ struct SettingsView: View { } } + private var historySection: some View { + Section { + Toggle(isOn: Binding( + get: { historyEnabled }, + set: { newVal in + if !newVal && historyEnabled { + showHistoryDisableConfirm = true + } else { + historyEnabled = newVal + } + } + )) { + Label("Hörverlauf aktivieren", systemImage: "clock.arrow.circlepath") + } + if historyEnabled { + LabeledContent("Einträge") { + Text("\(app.history.entries.count)") + .foregroundStyle(.secondary) + } + Button { + showHistoryExport = true + } label: { + Label("Verlauf als XML exportieren", systemImage: "square.and.arrow.up") + } + .disabled(app.history.entries.isEmpty) + } + } header: { + Text("Hörverlauf") + } footer: { + Text(historyEnabled + ? "Positionen werden vor jedem Sprung aufgezeichnet (max. 200 Einträge). Daten verbleiben lokal auf diesem Gerät." + : "Protokolliert, wo du vor einem Sprung warst, damit du zurücknavigieren kannst. Standardmäßig deaktiviert.") + } + } + private var appearanceSection: some View { Section { Picker("Bibliotheks-Ansicht", selection: $layoutRaw) { @@ -138,6 +211,10 @@ struct SettingsView: View { } } Toggle("Beim Start automatisch aktualisieren", isOn: $autoRefreshOnLaunch) + Picker("Sprache", selection: Binding(get: { app.language }, set: { app.language = $0 })) { + Text("Deutsch").tag("de") + Text("English").tag("en") + } } header: { Text("Darstellung") } @@ -219,6 +296,38 @@ struct SettingsView: View { .formStyle(.grouped) } + private var historyPane: some View { + Form { + Toggle(isOn: Binding( + get: { historyEnabled }, + set: { newVal in + if !newVal && historyEnabled { showHistoryDisableConfirm = true } + else { historyEnabled = newVal } + } + )) { + Text("Hörverlauf aktivieren") + } + if historyEnabled { + LabeledContent("Einträge", value: "\(app.history.entries.count)") + HStack { + Spacer() + Button("Verlauf als XML exportieren") { + if let url = app.history.exportXML() { + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + } + .disabled(app.history.entries.isEmpty) + } + } + Text(historyEnabled + ? "Positionen werden vor jedem Sprung aufgezeichnet (max. 200 Einträge)." + : "Protokolliert Positionen vor Sprüngen, damit du zurücknavigieren kannst.") + .font(.caption) + .foregroundStyle(.secondary) + } + .formStyle(.grouped) + } + private var appearancePane: some View { Form { Picker("Bibliotheks-Ansicht", selection: $layoutRaw) { @@ -227,6 +336,10 @@ struct SettingsView: View { } } Toggle("Beim Start automatisch aktualisieren", isOn: $autoRefreshOnLaunch) + Picker("Sprache", selection: Binding(get: { app.language }, set: { app.language = $0 })) { + Text("Deutsch").tag("de") + Text("English").tag("en") + } } .formStyle(.grouped) } @@ -248,3 +361,15 @@ struct SettingsView: View { return "\(v) (\(b))" } } + +#if os(iOS) +import UIKit + +struct ShareSheet: UIViewControllerRepresentable { + let url: URL + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: [url], applicationActivities: nil) + } + func updateUIViewController(_ uvc: UIActivityViewController, context: Context) {} +} +#endif diff --git a/ABS Client/Audiobookshelf swift/de.lproj/Localizable.strings b/ABS Client/Audiobookshelf swift/de.lproj/Localizable.strings new file mode 100644 index 0000000..cd60c9e --- /dev/null +++ b/ABS Client/Audiobookshelf swift/de.lproj/Localizable.strings @@ -0,0 +1,152 @@ +/* German is the default — keys match display values */ + +/* Navigation / Tabs */ +"nav.libraries" = "Bibliotheken"; +"nav.offline" = "Offline"; +"nav.downloaded" = "Heruntergeladen"; +"nav.history" = "Verlauf"; +"nav.library" = "Bibliothek"; + +/* Player */ +"player.preparing" = "Wiedergabe wird vorbereitet …"; +"player.stop" = "Wiedergabe beenden"; +"player.sleep_timer" = "Sleep-Timer"; +"player.speed" = "Geschwindigkeit"; +"player.chapters_bookmarks" = "Kapitel & Lesezeichen"; +"player.history_recent" = "Letzter Verlauf"; +"player.history_all" = "Gesamter Verlauf"; + +/* Sleep Timer */ +"sleep.off" = "Aus"; +"sleep.10min" = "10 Minuten"; +"sleep.20min" = "20 Minuten"; +"sleep.30min" = "30 Minuten"; +"sleep.1h" = "1 Stunde"; +"sleep.end_of_book" = "Bis Ende des Hörbuchs"; +"sleep.end_of_episode" = "Bis Ende der Folge"; +"sleep.end_of_chapter" = "Bis Ende des Kapitels"; + +/* Settings */ +"settings.title" = "Einstellungen"; +"settings.done" = "Fertig"; +"settings.connection" = "Verbindung"; +"settings.server" = "Server"; +"settings.user" = "Benutzer"; +"settings.online" = "Online"; +"settings.offline" = "Offline"; +"settings.queued" = "%lld wartend"; +"settings.logout" = "Abmelden / Server wechseln"; +"settings.logout_confirm_title" = "Mit Server abmelden?"; +"settings.logout_confirm_action" = "Abmelden"; +"settings.logout_confirm_message" = "Du wirst zurück zur Login-Maske geschickt. Heruntergeladene Inhalte bleiben."; +"settings.logout_confirm_message_mac" = "Du wirst zur Login-Maske zurückgesetzt. Heruntergeladene Hörbücher bleiben erhalten."; +"settings.cancel" = "Abbrechen"; +"settings.connection_footer" = "Abmelden setzt die gespeicherten Anmeldedaten zurück. Heruntergeladene Inhalte bleiben."; + +"settings.playback" = "Wiedergabe"; +"settings.skip_duration" = "Sprung-Dauer"; +"settings.skip_footer" = "Gilt für die Skip-Knöpfe in der Player-Leiste und auf dem Sperrbildschirm."; +"settings.skip_footer_mac" = "Gilt für die Skip-Knöpfe in der Player-Leiste und Medientasten."; + +"settings.history_section" = "Hörverlauf"; +"settings.history_enable" = "Hörverlauf aktivieren"; +"settings.history_entries" = "Einträge"; +"settings.history_export" = "Verlauf als XML exportieren"; +"settings.history_footer_on" = "Positionen werden vor jedem Sprung aufgezeichnet (max. 200 Einträge). Daten verbleiben lokal auf diesem Gerät."; +"settings.history_footer_off" = "Protokolliert, wo du vor einem Sprung warst, damit du zurücknavigieren kannst. Standardmäßig deaktiviert."; +"settings.history_disable_title" = "Hörverlauf deaktivieren?"; +"settings.history_disable_action" = "Verlauf löschen & deaktivieren"; +"settings.history_disable_message" = "Der gesamte aufgezeichnete Hörverlauf wird unwiderruflich gelöscht."; +"settings.history_footer_on_mac" = "Positionen werden vor jedem Sprung aufgezeichnet (max. 200 Einträge)."; +"settings.history_footer_off_mac" = "Protokolliert Positionen vor Sprüngen, damit du zurücknavigieren kannst."; + +"settings.appearance" = "Darstellung"; +"settings.library_view" = "Bibliotheks-Ansicht"; +"settings.auto_refresh" = "Beim Start automatisch aktualisieren"; + +"settings.downloads" = "Downloads"; +"settings.downloaded_count" = "%lld Einträge"; +"settings.downloads_footer" = "Heruntergeladene Hörbücher und Folgen können einzeln über das Kontextmenü gelöscht werden."; + +"settings.about" = "Über"; +"settings.version" = "Version"; + +"settings.language" = "Sprache"; +"settings.language_de" = "Deutsch"; +"settings.language_en" = "Englisch"; + +/* Playback Details Sheet */ +"details.chapters" = "Kapitel"; +"details.history" = "Verlauf"; +"details.bookmarks" = "Lesezeichen"; +"details.chapter_start" = "Kapitelanfang"; +"details.chapter_prev" = "Vorheriges Kapitel"; +"details.chapter_next" = "Nächstes Kapitel"; +"details.chapter_end" = "Kapitelende"; +"details.no_chapters" = "Keine Kapitel"; +"details.no_chapters_desc" = "Dieses Hörbuch enthält keine Kapitelinformationen."; +"details.no_history" = "Kein Verlauf"; +"details.no_history_desc" = "Positionen werden beim Springen aufgezeichnet."; +"details.clear_history" = "Verlauf löschen"; +"details.no_bookmarks" = "Keine Lesezeichen"; +"details.no_bookmarks_desc" = "Tippe auf \"+\" um die aktuelle Position zu merken."; +"details.add_bookmark" = "Lesezeichen hinzufügen"; +"details.bookmark_name_title" = "Lesezeichen hinzufügen"; +"details.bookmark_name_placeholder" = "Name"; +"details.bookmark_name_message" = "Gib einen Namen für dieses Lesezeichen ein."; +"details.bookmark_add" = "Hinzufügen"; + +/* Full History View */ +"history.title" = "Gesamter Verlauf"; +"history.empty" = "Kein Verlauf"; +"history.empty_desc" = "Der Hörverlauf ist aktivierbar in den Einstellungen."; +"history.clear" = "Verlauf löschen"; +"history.just_now" = "Gerade eben"; +"history.minutes_ago" = "vor %lld Min."; +"history.hours_ago" = "vor %lld Std."; +"history.days_ago" = "vor %lld Tag(en)"; +"history.not_playing" = "Kein aktives Hörbuch"; +"history.other_item" = "(anderes Hörbuch)"; + +/* Download context menu */ +"download.select_episodes" = "Episoden zum Download in der Podcast-Ansicht auswählen"; +"download.save_offline" = "Für Offline herunterladen"; +"download.cancel" = "Download abbrechen"; +"download.delete" = "Heruntergeladene Dateien löschen"; + +/* Library / Main */ +"library.loading" = "Lade Bibliothek …"; +"library.error" = "Fehler"; +"library.empty" = "Keine Hörbücher"; +"library.empty_desc" = "Diese Auswahl enthält noch keine Hörbücher."; +"library.refresh" = "Bibliothek, Cover und Hörfortschritte neu laden"; +"library.view_toggle" = "Zwischen Kachel- und Listenansicht wechseln"; +"library.view_label" = "Ansicht"; +"library.settings" = "Einstellungen"; + +/* Login */ +"login.title" = "ABS Client"; +"login.server_placeholder" = "https://mein-server.de"; +"login.username" = "Benutzername"; +"login.password" = "Passwort"; +"login.connect" = "Verbinden"; + +/* Podcast */ +"podcast.episodes" = "Episoden"; +"podcast.loading" = "Lade Folgen …"; +"podcast.no_date" = "Kein Datum"; + +/* Sidebar / macOS */ +"sidebar.libraries" = "Bibliotheken"; +"sidebar.offline" = "Offline"; +"sidebar.history" = "Verlauf"; +"sidebar.logout" = "Abmelden"; +"sidebar.app_title" = "ABS Client"; +"sidebar.status_online" = "Online"; +"sidebar.status_offline" = "Offline"; + +/* Status */ +"status.online" = "Online – Fortschritt wird synchronisiert"; +"status.offline_queued" = "Offline – %lld Eintrag/Einträge wartend"; +"status.syncing" = "%lld Synchronisationen wartend"; +"status.logged_in_as" = "Angemeldet als %@"; diff --git a/ABS Client/Audiobookshelf swift/en.lproj/Localizable.strings b/ABS Client/Audiobookshelf swift/en.lproj/Localizable.strings new file mode 100644 index 0000000..6e5d672 --- /dev/null +++ b/ABS Client/Audiobookshelf swift/en.lproj/Localizable.strings @@ -0,0 +1,152 @@ +/* English translations */ + +/* Navigation / Tabs */ +"nav.libraries" = "Libraries"; +"nav.offline" = "Offline"; +"nav.downloaded" = "Downloaded"; +"nav.history" = "History"; +"nav.library" = "Library"; + +/* Player */ +"player.preparing" = "Preparing playback …"; +"player.stop" = "Stop playback"; +"player.sleep_timer" = "Sleep Timer"; +"player.speed" = "Speed"; +"player.chapters_bookmarks" = "Chapters & Bookmarks"; +"player.history_recent" = "Recent History"; +"player.history_all" = "Full History"; + +/* Sleep Timer */ +"sleep.off" = "Off"; +"sleep.10min" = "10 minutes"; +"sleep.20min" = "20 minutes"; +"sleep.30min" = "30 minutes"; +"sleep.1h" = "1 hour"; +"sleep.end_of_book" = "Until end of book"; +"sleep.end_of_episode" = "Until end of episode"; +"sleep.end_of_chapter" = "Until end of chapter"; + +/* Settings */ +"settings.title" = "Settings"; +"settings.done" = "Done"; +"settings.connection" = "Connection"; +"settings.server" = "Server"; +"settings.user" = "User"; +"settings.online" = "Online"; +"settings.offline" = "Offline"; +"settings.queued" = "%lld pending"; +"settings.logout" = "Sign out / Switch server"; +"settings.logout_confirm_title" = "Sign out of server?"; +"settings.logout_confirm_action" = "Sign out"; +"settings.logout_confirm_message" = "You will be taken back to the login screen. Downloaded content will remain."; +"settings.logout_confirm_message_mac" = "You will be returned to the login screen. Downloaded audiobooks will remain."; +"settings.cancel" = "Cancel"; +"settings.connection_footer" = "Signing out resets the stored credentials. Downloaded content remains."; + +"settings.playback" = "Playback"; +"settings.skip_duration" = "Skip Duration"; +"settings.skip_footer" = "Applies to skip buttons in the player bar and on the lock screen."; +"settings.skip_footer_mac" = "Applies to skip buttons in the player bar and media keys."; + +"settings.history_section" = "Listening History"; +"settings.history_enable" = "Enable listening history"; +"settings.history_entries" = "Entries"; +"settings.history_export" = "Export history as XML"; +"settings.history_footer_on" = "Positions are recorded before every seek (max. 200 entries). Data stays local on this device."; +"settings.history_footer_off" = "Records where you were before a seek so you can navigate back. Disabled by default."; +"settings.history_disable_title" = "Disable listening history?"; +"settings.history_disable_action" = "Delete history & disable"; +"settings.history_disable_message" = "The entire recorded listening history will be permanently deleted."; +"settings.history_footer_on_mac" = "Positions are recorded before every seek (max. 200 entries)."; +"settings.history_footer_off_mac" = "Records positions before seeks so you can navigate back."; + +"settings.appearance" = "Appearance"; +"settings.library_view" = "Library View"; +"settings.auto_refresh" = "Refresh automatically on launch"; + +"settings.downloads" = "Downloads"; +"settings.downloaded_count" = "%lld items"; +"settings.downloads_footer" = "Downloaded audiobooks and episodes can be deleted individually via the context menu."; + +"settings.about" = "About"; +"settings.version" = "Version"; + +"settings.language" = "Language"; +"settings.language_de" = "German"; +"settings.language_en" = "English"; + +/* Playback Details Sheet */ +"details.chapters" = "Chapters"; +"details.history" = "History"; +"details.bookmarks" = "Bookmarks"; +"details.chapter_start" = "Chapter start"; +"details.chapter_prev" = "Previous chapter"; +"details.chapter_next" = "Next chapter"; +"details.chapter_end" = "Chapter end"; +"details.no_chapters" = "No Chapters"; +"details.no_chapters_desc" = "This audiobook contains no chapter information."; +"details.no_history" = "No History"; +"details.no_history_desc" = "Positions are recorded when you seek."; +"details.clear_history" = "Clear history"; +"details.no_bookmarks" = "No Bookmarks"; +"details.no_bookmarks_desc" = "Tap \"+\" to mark the current position."; +"details.add_bookmark" = "Add bookmark"; +"details.bookmark_name_title" = "Add Bookmark"; +"details.bookmark_name_placeholder" = "Name"; +"details.bookmark_name_message" = "Enter a name for this bookmark."; +"details.bookmark_add" = "Add"; + +/* Full History View */ +"history.title" = "Full History"; +"history.empty" = "No History"; +"history.empty_desc" = "Listening history can be enabled in Settings."; +"history.clear" = "Clear history"; +"history.just_now" = "Just now"; +"history.minutes_ago" = "%lld min. ago"; +"history.hours_ago" = "%lld hr. ago"; +"history.days_ago" = "%lld day(s) ago"; +"history.not_playing" = "No active audiobook"; +"history.other_item" = "(different audiobook)"; + +/* Download context menu */ +"download.select_episodes" = "Select episodes for download in podcast view"; +"download.save_offline" = "Save for offline"; +"download.cancel" = "Cancel download"; +"download.delete" = "Delete downloaded files"; + +/* Library / Main */ +"library.loading" = "Loading library …"; +"library.error" = "Error"; +"library.empty" = "No Audiobooks"; +"library.empty_desc" = "This selection does not contain any audiobooks yet."; +"library.refresh" = "Reload library, covers and progress"; +"library.view_toggle" = "Switch between grid and list view"; +"library.view_label" = "View"; +"library.settings" = "Settings"; + +/* Login */ +"login.title" = "ABS Client"; +"login.server_placeholder" = "https://my-server.com"; +"login.username" = "Username"; +"login.password" = "Password"; +"login.connect" = "Connect"; + +/* Podcast */ +"podcast.episodes" = "Episodes"; +"podcast.loading" = "Loading episodes …"; +"podcast.no_date" = "No date"; + +/* Sidebar / macOS */ +"sidebar.libraries" = "Libraries"; +"sidebar.offline" = "Offline"; +"sidebar.history" = "History"; +"sidebar.logout" = "Sign out"; +"sidebar.app_title" = "ABS Client"; +"sidebar.status_online" = "Online"; +"sidebar.status_offline" = "Offline"; + +/* Status */ +"status.online" = "Online – progress is being synced"; +"status.offline_queued" = "Offline – %lld item(s) pending"; +"status.syncing" = "%lld syncs pending"; +"status.logged_in_as" = "Signed in as %@";