Add chapters, history, bookmarks, live download progress, and i18n

- Chapter navigation with auto-scroll to current chapter and end-of-chapter sleep timer
- Opt-in listening history (local-only) with XML export and per-item quick menu
- Bookmarks with server sync via Audiobookshelf API
- Live MB counter during downloads via URLSessionDownloadTask delegate
- In-progress downloads shown in "Heruntergeladen" with dimmed cover + ring overlay
- Cover image cache (50 MB memory / 500 MB disk URLCache)
- German/English localization (de.lproj, en.lproj)
- Loading spinner now triggers immediately on view switch instead of waiting for the network

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-05-25 18:40:52 +02:00
parent 15d8e71d09
commit fa47cae664
21 changed files with 1751 additions and 119 deletions

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 }

View File

@@ -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?
}

View File

@@ -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

View File

@@ -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)) ?? []
}
}

View File

@@ -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<Void, Never>] = [:]
@@ -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..<maxAttempts {
try Task.checkCancellation()
do {
let (tempURL, response): (URL, URLResponse)
if let resume = resumeData {
(tempURL, response) = try await session.download(resumeFrom: resume)
} else {
(tempURL, response) = try await session.download(for: request)
}
let (tempURL, response) = try await streamingDownload(
request: request,
resumeData: resumeData,
downloadKey: downloadKey
)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
try? FileManager.default.removeItem(at: tempURL)
throw URLError(.badServerResponse)
@@ -262,6 +322,32 @@ final class DownloadManager {
throw lastError
}
/// Runs a single download attempt via URLSessionDownloadTask, reporting byte
/// progress to `inFlightBytes` and moving the system-temp file to a stable
/// location before returning.
private func streamingDownload(
request: URLRequest,
resumeData: Data?,
downloadKey: String
) async throws -> (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)))
}
}

View File

@@ -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 = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
xml += "<history exported=\"\(formatter.string(from: Date()))\">\n"
for entry in entries {
xml += " <entry>\n"
xml += " <timestamp>\(formatter.string(from: entry.timestamp))</timestamp>\n"
xml += " <itemTitle>\(xmlEscape(entry.itemTitle))</itemTitle>\n"
xml += " <itemAuthor>\(xmlEscape(entry.itemAuthor))</itemAuthor>\n"
if let chapter = entry.chapterTitle {
xml += " <chapter>\(xmlEscape(chapter))</chapter>\n"
}
if let epId = entry.episodeId {
xml += " <episodeId>\(xmlEscape(epId))</episodeId>\n"
}
xml += " <position>\(entry.position)</position>\n"
xml += " </entry>\n"
}
xml += "</history>\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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
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)) ?? []
}
}

View File

@@ -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() {

View File

@@ -29,6 +29,7 @@ struct ContentView: View {
LoginView()
}
}
.environment(\.locale, Locale(identifier: app.language))
#if os(iOS)
.frame(maxWidth: .infinity, maxHeight: .infinity)
#else

View File

@@ -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)
}
}

View File

@@ -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) }
}
}

View File

@@ -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)
}

View File

@@ -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) {

View File

@@ -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"
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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 %@";

View File

@@ -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 %@";