Compare commits

...

4 Commits

Author SHA1 Message Date
Scarriffle
77342d619b Rename app to Nook (display name only)
Changes the user-visible name from "ABS Client" to "Nook" across the
iOS home screen, macOS Dock/menu bar, login header, sidebar header, and
README. Bundle ID (com.local.ABS-Client), keychain key, and background
download session ID are intentionally left unchanged so the existing
App Store listing (ID 6770326901) updates in place — installed users
keep the app, stored credentials, and downloads.

- Build settings: INFOPLIST_KEY_CFBundleDisplayName, CFBundleName,
  PRODUCT_NAME → "Nook" (Debug + Release); built app is now Nook.app
- Info-iOS.plist: CFBundleDisplayName + CFBundleName → "Nook"
- Localizable.strings (de + en): login.title + sidebar.app_title → "Nook"
- LoginView: hardcoded headers → "Nook"
- README: branding text → "Nook" (git repo URLs and App Store URL
  unchanged — repo and listing rename happen separately)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-28 11:35:26 +02:00
Scarriffle
a17a61ad9a Home screen, library-wide search, splash with orbital particles, macOS download fix
- Add Netflix-style Home with recent audiobooks/podcasts, list/grid toggle shared with Library
- Add searchable libraries — audiobooks filter on title/author; podcasts also index and search episodes across the whole library
- Add unified context menu (download / progress remove) across Home, Library, and PodcastDetail
- Rework cover badges: listened checkmark top-right, download indicator bottom-right; hide progress bar when finished
- Add AirPlay picker in PlayerBar
- Replace splash with four-phase animation: orbital particles → attract → flash → fade; cross-platform iOS+macOS, runtime AppIcon lookup
- Fix macOS parallel downloads: align connection settings with iOS, raise timeoutIntervalForRequest to 300s
- Fix progress deletion using server-side progress UUID (DELETE /api/me/progress/:id)
- Bump CFBundleVersion to 8

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 08:54:43 +02:00
Scarriffle
1dbd133b75 Remove iPad references from README — app is iPhone- and Mac-only
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 19:43:59 +02:00
Scarriffle
fec48cb2af Update README with App Store availability and current feature list
- Remove "not yet available" warning; app is now live on iOS and macOS App Store
- Add direct App Store link (works for both platforms)
- Reorganize features into sections: library, playback, downloads & offline,
  progress sync, language & platform
- Document recently shipped features: bidirectional progress sync, chapters,
  bookmarks, history with XML export, live download progress, background
  downloads, live activities, parallel/sequential download modes, mobile-data
  toggle, DE/EN localization

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 19:41:05 +02:00
25 changed files with 1491 additions and 301 deletions

View File

@@ -37,7 +37,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
39614D0B2FB4D44500DBEF5E /* ABS Client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ABS Client.app"; sourceTree = BUILT_PRODUCTS_DIR; };
39614D0B2FB4D44500DBEF5E /* Nook.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Nook.app"; sourceTree = BUILT_PRODUCTS_DIR; };
39C427042FD180B10033EE46 /* DownloadActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadActivityAttributes.swift; sourceTree = "<group>"; };
39C427052FD180BA0033EE46 /* DownloadWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadWidgetBundle.swift; sourceTree = "<group>"; };
39C427062FD180C80033EE46 /* DownloadLiveActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadLiveActivityWidget.swift; sourceTree = "<group>"; };
@@ -106,7 +106,7 @@
39614D0C2FB4D44500DBEF5E /* Products */ = {
isa = PBXGroup;
children = (
39614D0B2FB4D44500DBEF5E /* ABS Client.app */,
39614D0B2FB4D44500DBEF5E /* Nook.app */,
39C4270B2FD189220033EE46 /* DownloadWidgetExtensionExtension.appex */,
);
name = Products;
@@ -155,7 +155,7 @@
packageProductDependencies = (
);
productName = "Audiobookshelf swift";
productReference = 39614D0B2FB4D44500DBEF5E /* ABS Client.app */;
productReference = 39614D0B2FB4D44500DBEF5E /* Nook.app */;
productType = "com.apple.product-type.application";
};
39C4270A2FD189220033EE46 /* DownloadWidgetExtensionExtension */ = {
@@ -395,7 +395,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = PP34X97WS3;
"ENABLE_APP_SANDBOX[sdk=macosx*]" = YES;
ENABLE_PREVIEWS = YES;
@@ -404,8 +404,8 @@
"GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
"INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
"INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
INFOPLIST_KEY_CFBundleDisplayName = "ABS Client";
INFOPLIST_KEY_CFBundleName = "ABS Client";
INFOPLIST_KEY_CFBundleDisplayName = "Nook";
INFOPLIST_KEY_CFBundleName = "Nook";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
@@ -423,9 +423,9 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 2.5;
MARKETING_VERSION = 3.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
PRODUCT_NAME = "ABS Client";
PRODUCT_NAME = "Nook";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -449,7 +449,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = PP34X97WS3;
"ENABLE_APP_SANDBOX[sdk=macosx*]" = YES;
ENABLE_PREVIEWS = YES;
@@ -458,8 +458,8 @@
"GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
"INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
"INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
INFOPLIST_KEY_CFBundleDisplayName = "ABS Client";
INFOPLIST_KEY_CFBundleName = "ABS Client";
INFOPLIST_KEY_CFBundleDisplayName = "Nook";
INFOPLIST_KEY_CFBundleName = "Nook";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
@@ -477,9 +477,9 @@
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 2.5;
MARKETING_VERSION = 3.1;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
PRODUCT_NAME = "ABS Client";
PRODUCT_NAME = "Nook";
REGISTER_APP_GROUPS = YES;
SDKROOT = auto;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -500,7 +500,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = DownloadWidgetExtension/Info.plist;
@@ -512,13 +512,15 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.1;
MARKETING_VERSION = 2.6;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client.DownloadWidgetExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
@@ -533,7 +535,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 2;
CURRENT_PROJECT_VERSION = 8;
DEVELOPMENT_TEAM = PP34X97WS3;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = DownloadWidgetExtension/Info.plist;
@@ -545,13 +547,15 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.1;
MARKETING_VERSION = 2.6;
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client.DownloadWidgetExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;

View File

@@ -9,9 +9,9 @@
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForTesting = "NO"
buildForRunning = "NO"
buildForProfiling = "NO"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
@@ -31,7 +31,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "39614D0A2FB4D44400DBEF5E"
BuildableName = "ABS Client.app"
BuildableName = "Nook.app"
BlueprintName = "Audiobookshelf swift"
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
</BuildableReference>
@@ -58,16 +58,15 @@
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2"
queueDebuggingEnableBacktraceRecording = "Yes">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "39614D0A2FB4D44400DBEF5E"
BuildableName = "ABS Client.app"
BuildableName = "Nook.app"
BlueprintName = "Audiobookshelf swift"
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</MacroExpansion>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
@@ -99,7 +98,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "39614D0A2FB4D44400DBEF5E"
BuildableName = "ABS Client.app"
BuildableName = "Nook.app"
BlueprintName = "Audiobookshelf swift"
ReferencedContainer = "container:Audiobookshelf swift.xcodeproj">
</BuildableReference>

View File

@@ -7,21 +7,18 @@
<key>Audiobookshelf swift.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
<integer>1</integer>
</dict>
<key>DownloadWidgetExtensionExtension.xcscheme_^#shared#^_</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>39614D0A2FB4D44400DBEF5E</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>39C4270A2FD189220033EE46</key>
<dict>
<key>primary</key>

View File

@@ -56,9 +56,18 @@ struct Audiobookshelf_swiftApp: App {
#if os(iOS)
private func configureAudioSession() {
// Nur die Kategorie registrieren setActive(true) passiert erst in play(),
// damit beim App-Start keine laufende Fremd-Wiedergabe unterbrochen wird.
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
// `.spokenAudio` + `.longFormAudio` policy: iOS-Hinweis dass es sich um
// ernsthafte Audio-Wiedergabe handelt (nicht Tasten-Sounds). Aktiviert
// u. a. das AirPlay-Routing im Control Center / Lockscreen und sorgt
// für richtigen Ducking-/Interruption-Behavior bei Anrufen.
// setActive(true) passiert erst in play() damit beim App-Start
// keine laufende Fremd-Wiedergabe unterbrochen wird.
try? AVAudioSession.sharedInstance().setCategory(
.playback,
mode: .spokenAudio,
policy: .longFormAudio,
options: []
)
}
#endif
}

View File

@@ -13,6 +13,11 @@ struct Library: Codable, Identifiable, Hashable {
let mediaType: String?
}
/// Wichtig für `ForEach`: Synthetische Podcast-Episoden-Items (z.B. aus der
/// library-weiten Folgensuche) teilen sich denselben `id` (Podcast-Container-ID)
/// und unterscheiden sich nur über `episodeId`. Default-`Identifiable` würde
/// hier kollidieren. **Immer** `ForEach(items, id: \.syncKey)` benutzen wenn
/// die Liste sowohl Container als auch Episoden-Items enthalten kann.
struct LibraryItem: Codable, Identifiable, Hashable {
let id: String
let title: String
@@ -74,6 +79,10 @@ struct PlaybackProgress: Codable, Hashable {
var duration: Double
var isFinished: Bool
var updatedAt: Date
/// Server-assigned UUID for this progress entry. Required to delete it
/// Audiobookshelf's `DELETE /api/me/progress/:id` matches by this id, NOT
/// by libraryItemId. Optional so older persisted caches still decode.
var id: String?
var syncKey: String {
if let episodeId { return "\(itemId)|\(episodeId)" }

View File

@@ -75,7 +75,7 @@ final class ABSClient {
func fetchLibraries() async throws -> [Library] {
let req = try makeRequest(path: "/api/libraries")
let dto = try await perform(req, as: LibrariesResponseDTO.self)
return dto.libraries.map { Library(id: $0.id, name: $0.name, mediaType: $0.mediaType) }
return dto.libraries.map { Library(id: $0.id, name: Self.decodeHTMLEntities($0.name), mediaType: $0.mediaType) }
}
func fetchItems(libraryId: String) async throws -> [LibraryItem] {
@@ -84,6 +84,62 @@ final class ABSClient {
return dto.results.map { Self.toLibraryItem(from: $0) }
}
/// Audiobookshelf serves podcast titles straight from the RSS feed, which
/// often contains HTML entities (`&uuml;`, `&euro;`, `&#8364;` etc.).
/// SwiftUI renders those literally, so we decode them here once at the
/// API boundary every title/author/chapter/library name goes through
/// this before reaching the UI.
static func decodeHTMLEntities(_ s: String) -> String {
guard s.contains("&") else { return s }
var result = s
let named: [String: String] = [
"&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"",
"&apos;": "'", "&nbsp;": " ",
"&euro;": "", "&pound;": "£", "&yen;": "¥",
"&copy;": "©", "&reg;": "®", "&trade;": "",
"&hellip;": "", "&mdash;": "", "&ndash;": "",
"&laquo;": "«", "&raquo;": "»",
"&bdquo;": "", "&ldquo;": "\u{201C}", "&rdquo;": "\u{201D}",
"&lsquo;": "\u{2018}", "&rsquo;": "\u{2019}",
"&auml;": "ä", "&Auml;": "Ä",
"&ouml;": "ö", "&Ouml;": "Ö",
"&uuml;": "ü", "&Uuml;": "Ü",
"&szlig;": "ß",
"&eacute;": "é", "&Eacute;": "É",
"&egrave;": "è", "&Egrave;": "È",
"&agrave;": "à", "&Agrave;": "À",
"&acirc;": "â", "&Acirc;": "Â",
"&ecirc;": "ê", "&Ecirc;": "Ê",
"&icirc;": "î", "&Icirc;": "Î",
"&ocirc;": "ô", "&Ocirc;": "Ô",
"&ucirc;": "û", "&Ucirc;": "Û",
"&ntilde;": "ñ", "&Ntilde;": "Ñ",
"&ccedil;": "ç", "&Ccedil;": "Ç",
]
for (entity, char) in named {
result = result.replacingOccurrences(of: entity, with: char)
}
// Numeric refs: &#1234; (decimal) or &#xABCD; (hex)
if result.contains("&#"),
let regex = try? NSRegularExpression(pattern: "&#(x?)([0-9A-Fa-f]+);") {
let nsRange = NSRange(result.startIndex..., in: result)
// Reverse so prior replacements don't shift later ranges.
let matches = regex.matches(in: result, range: nsRange).reversed()
for match in matches {
guard let full = Range(match.range, in: result),
let hexFlag = Range(match.range(at: 1), in: result),
let num = Range(match.range(at: 2), in: result) else { continue }
let isHex = !result[hexFlag].isEmpty
let numStr = String(result[num])
let code = isHex ? UInt32(numStr, radix: 16) : UInt32(numStr)
if let code, let scalar = Unicode.Scalar(code) {
result.replaceSubrange(full, with: String(scalar))
}
}
}
return result
}
private static func toLibraryItem(from raw: LibraryItemDTO) -> LibraryItem {
let meta = raw.media?.metadata
let mediaType = raw.mediaType ?? "book"
@@ -99,16 +155,16 @@ final class ABSClient {
let authorString = meta?.authorName ?? meta?.author ?? (mediaType == "podcast" ? "Podcast" : "Unbekannter Autor")
var item = LibraryItem(
id: raw.id,
title: meta?.title ?? "Unbekannt",
author: authorString,
title: decodeHTMLEntities(meta?.title ?? "Unbekannt"),
author: decodeHTMLEntities(authorString),
durationSeconds: raw.media?.duration ?? 0,
audioFiles: files
)
item.mediaType = mediaType
item.description = meta?.description
item.description = meta?.description.map { decodeHTMLEntities($0) }
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 Chapter(id: id, start: start, end: end, title: decodeHTMLEntities(title))
}
return item
}
@@ -129,7 +185,7 @@ final class ABSClient {
)
return PodcastEpisode(
id: ep.id,
title: ep.title ?? "Folge",
title: Self.decodeHTMLEntities(ep.title ?? "Folge"),
pubDate: ep.pubDate,
publishedAtMillis: ep.publishedAt,
season: ep.season,
@@ -167,7 +223,8 @@ final class ABSClient {
currentTime: dto.currentTime ?? 0,
duration: dto.duration ?? 0,
isFinished: dto.isFinished ?? false,
updatedAt: Self.parseLastUpdate(dto.lastUpdate)
updatedAt: Self.parseLastUpdate(dto.lastUpdate),
id: dto.id
)
}
@@ -194,6 +251,26 @@ final class ABSClient {
}
}
/// Removes a media-progress entry on the server. Audiobookshelf's
/// `DELETE /api/me/progress/:id` matches by the progress entry's UUID,
/// NOT by libraryItemId (which is what PATCH uses) this is the source
/// of the long-running "delete didn't stick" bug. 404 is treated as
/// success (entry already gone).
func deleteProgress(progressId: String) async throws {
let req = try makeRequest(
path: "/api/me/progress/\(progressId)",
method: "DELETE"
)
let (_, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw ABSClientError.httpStatus(0)
}
if http.statusCode == 404 { return }
guard (200..<300).contains(http.statusCode) else {
throw ABSClientError.httpStatus(http.statusCode)
}
}
func fetchAllProgress() async throws -> [PlaybackProgress] {
let req = try makeRequest(path: "/api/me")
let dto = try await perform(req, as: MeResponseDTO.self)
@@ -205,7 +282,8 @@ final class ABSClient {
currentTime: p.currentTime ?? 0,
duration: p.duration ?? 0,
isFinished: p.isFinished ?? false,
updatedAt: Self.parseLastUpdate(p.lastUpdate)
updatedAt: Self.parseLastUpdate(p.lastUpdate),
id: p.id
)
}
}

View File

@@ -23,6 +23,12 @@ final class AppState {
/// Used to show progress bars on covers in the library views.
var progressCache: [String: PlaybackProgress] = [:]
/// Cached LibraryItem details (cover, title, author, chapters) per syncKey.
/// Populated whenever the user actually plays an item, persisted to disk so
/// the Home screen can show "recently played" cards on first launch without
/// any network round-trip.
var recentItemsCache: [String: LibraryItem] = [:]
/// Server-progress that's newer than local but we haven't applied yet because
/// playback is active/paused. Offered via alert on next play; cleared on
/// item change.
@@ -70,6 +76,7 @@ final class AppState {
await NotificationManager.shared.requestPermissionIfNeeded()
#endif
auth.restoreSession()
loadRecentItemsCache()
network.start { [weak self] online in
guard let self else { return }
if online {
@@ -220,6 +227,10 @@ final class AppState {
}
currentItem = workItem
// Cache full item details for the Home screen we paid the
// fetchItemDetail cost above anyway, no reason to redo it later.
recentItemsCache[workItem.syncKey] = workItem
persistRecentItems()
player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
if player.errorMessage == nil {
player.play()
@@ -244,6 +255,52 @@ final class AppState {
await play(item: item, overrideStartAt: entry.position)
}
/// Removes a media-progress entry from the server AND the local caches
/// (`progressCache`, `recentItemsCache`, pending sync queue). The Home
/// screen reacts immediately because it reads from `progressCache`. If
/// the server call fails we still clear the local cache the next
/// `refreshProgressCache()` will re-sync from authoritative state (and
/// the entry would come back if the server delete didn't go through).
func removeProgress(itemId: String, episodeId: String? = nil) async {
let key: String
if let episodeId { key = "\(itemId)|\(episodeId)" } else { key = itemId }
// Grab the server-assigned progress UUID BEFORE we wipe the cache
// Audiobookshelf's DELETE endpoint matches by this UUID, not by
// libraryItemId, so we need it to actually hit the right entry.
var progressId = progressCache[key]?.id
progressCache.removeValue(forKey: key)
recentItemsCache.removeValue(forKey: key)
persistRecentItems()
guard network.isOnline else { return }
// Cache might be from before the `id` field was added fall back to
// a fresh fetchProgress to obtain it.
if progressId == nil {
progressId = (try? await client.fetchProgress(itemId: itemId, episodeId: episodeId))?.id
}
guard let pid = progressId else { return }
try? await client.deleteProgress(progressId: pid)
}
/// Plays an item from the Home screen / recent-items cache. Books resume
/// via the normal flow, but podcasts need an extra step: the cached
/// LibraryItem carries an `episodeId` but no `audioFiles` (those live on
/// the PodcastEpisode, not the podcast container). We fetch the episode
/// list, locate the matching one, and hand off to `play(podcast:episode:)`.
func playFromRecents(_ item: LibraryItem) async {
guard item.isPodcastEpisode, let episodeId = item.episodeId else {
await play(item: item)
return
}
guard network.isOnline,
let (podcast, episodes) = try? await client.fetchEpisodes(podcastItemId: item.id),
let episode = episodes.first(where: { $0.id == episodeId }) else { return }
await play(podcast: podcast, episode: episode)
}
/// Convenience for podcast episodes.
func play(podcast: LibraryItem, episode: PodcastEpisode) async {
var synthetic = LibraryItem(
@@ -323,6 +380,105 @@ final class AppState {
syncTimer = timer
}
// MARK: - Recent items cache
private var recentItemsFile: URL {
AppPaths.supportDirectory.appendingPathComponent("recent-items.json")
}
func loadRecentItemsCache() {
guard let data = try? Data(contentsOf: recentItemsFile),
let decoded = try? JSONDecoder().decode([String: LibraryItem].self, from: data) else { return }
recentItemsCache = decoded
}
private func persistRecentItems() {
// Cap at 100 most-recently-touched syncKeys (based on progressCache) so
// the file doesn't grow unbounded for users with massive listening history.
let keep = Set(progressCache.values
.sorted { $0.updatedAt > $1.updatedAt }
.prefix(100)
.map { $0.syncKey })
let trimmed = recentItemsCache.filter { keep.contains($0.key) || recentItemsCache.count <= 100 }
do {
try FileManager.default.createDirectory(at: AppPaths.supportDirectory, withIntermediateDirectories: true)
let data = try JSONEncoder().encode(trimmed)
try data.write(to: recentItemsFile, options: .atomic)
recentItemsCache = trimmed
} catch {
// non-fatal
}
}
/// Fills `recentItemsCache` for the top-N most recent progress entries that
/// don't yet have details. Pulls from downloaded items first (no network),
/// then `fetchItemDetail` for the rest. Called by HomeView on appear.
func fillRecentItemsCache(limit: Int = 20) async {
let topKeys = progressCache.values
.filter { !$0.isFinished }
.sorted { $0.updatedAt > $1.updatedAt }
.prefix(limit)
.map { $0.syncKey }
var newlyAdded = false
for key in topKeys {
// Skip if already cached AND has audio files. A cached podcast
// item with empty audioFiles is a stale entry from the old
// fillRecentItemsCache path (fetchItemDetail returned a container,
// not an episode) re-fetch via fetchEpisodes below.
if let existing = recentItemsCache[key], !existing.audioFiles.isEmpty { continue }
// 1) Try downloaded items first
if let downloaded = downloads.downloadedItems[key] {
let files: [AudioFile] = downloaded.tracks.enumerated().map { idx, t in
AudioFile(ino: t.ino, filename: t.filename, ext: "", durationSeconds: t.durationSeconds, index: idx)
}
var li = LibraryItem(
id: downloaded.itemId,
title: downloaded.title,
author: downloaded.author,
durationSeconds: downloaded.durationSeconds,
audioFiles: files
)
if let episodeId = downloaded.episodeId {
li.mediaType = "podcast"
li.episodeId = episodeId
}
recentItemsCache[key] = li
newlyAdded = true
continue
}
// 2) Fetch from server
guard network.isOnline,
let progress = progressCache[key] else { continue }
if let episodeId = progress.episodeId {
// Podcast episode: fetch episodes and synthesize a LibraryItem
// that carries the episode's title + audioFile (analogous to
// `play(podcast:episode:)`). Without this the cached item has
// the podcast container's title, not the episode title.
guard let (podcast, episodes) = try? await client.fetchEpisodes(podcastItemId: progress.itemId),
let episode = episodes.first(where: { $0.id == episodeId }) else { continue }
var synthetic = LibraryItem(
id: podcast.id,
title: episode.title,
author: podcast.title,
durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds,
audioFiles: [episode.audioFile]
)
synthetic.mediaType = "podcast"
synthetic.episodeId = episode.id
recentItemsCache[key] = synthetic
newlyAdded = true
} else {
// Audiobook
guard let detail = try? await client.fetchItemDetail(itemId: progress.itemId) else { continue }
recentItemsCache[key] = detail
newlyAdded = true
}
}
if newlyAdded { persistRecentItems() }
}
private func reportProgress(force: Bool) {
guard let item = currentItem else { return }
let t = player.absoluteCurrentTime

View File

@@ -146,20 +146,23 @@ final class DownloadManager: NSObject, @unchecked Sendable {
let config = URLSessionConfiguration.background(withIdentifier: identifier)
config.sessionSendsLaunchEvents = true
config.isDiscretionary = false
// Tells iOS this is interactive (vs. opportunistic bulk transfer) so
// it scheduling gives us closer-to-foreground throughput. On `.background`
// sessions this is mostly advisory but still respected for prioritization.
config.networkServiceType = .responsiveData
// Allow up to 6 parallel HTTPS connections to the ABS host matches the
// default for foreground sessions and helps multi-track books download
// faster instead of being serialized to 1 connection.
config.httpMaximumConnectionsPerHost = 6
#else
// macOS hat keinen Background-Session-Daemon mit Auto-Retry
// gleiche Pool-/Service-Settings wie iOS, sonst hängt sich ein
// parallel laufender Download bei geteilter Bandbreite zu lange ohne
// Bytes auf und failed mit Timeout.
let config = URLSessionConfiguration.default
config.networkServiceType = .responsiveData
config.httpMaximumConnectionsPerHost = 6
#endif
config.allowsCellularAccess = true
config.waitsForConnectivity = true
config.timeoutIntervalForRequest = 60
// 300s statt 60s: bei zwei parallelen großen Tracks kann eine Task
// temporär für deutlich mehr als 60s ohne Bytes bleiben, besonders
// auf macOS-Foreground-Sessions ohne iOS-Background-Retry-Logik.
config.timeoutIntervalForRequest = 300
config.timeoutIntervalForResource = 60 * 60 * 24 // 24h big audiobooks
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}

View File

@@ -112,6 +112,13 @@ final class PlayerEngine {
let queue = AVQueuePlayer(items: trackPlayerItems)
queue.rate = rate
// Enable AirPlay / Bluetooth / CarPlay routing. Without this, AVPlayer
// refuses to send audio to external devices even when the user picks
// them in Control Center / the route picker.
queue.allowsExternalPlayback = true
#if os(iOS)
queue.usesExternalPlaybackWhileExternalScreenIsActive = true
#endif
self.player = queue
let center = NotificationCenter.default

View File

@@ -0,0 +1,36 @@
import SwiftUI
import AVKit
/// Cross-platform SwiftUI wrapper around `AVRoutePickerView`. Opens the system
/// route picker (AirPlay devices, Bluetooth, HomePod, Apple TV, etc.) when
/// tapped. Available on iOS 11+ and macOS 10.15+ via AppKit.
///
/// The actual audio routing requires `AVPlayer.allowsExternalPlayback = true`
/// see `PlayerEngine.load(...)` where the queue player is configured.
#if os(iOS)
struct AirPlayPickerButton: UIViewRepresentable {
var tintColor: UIColor = .label
var activeTintColor: UIColor = .systemGreen
func makeUIView(context: Context) -> AVRoutePickerView {
let view = AVRoutePickerView()
view.tintColor = tintColor
view.activeTintColor = activeTintColor
view.prioritizesVideoDevices = false
return view
}
func updateUIView(_ uiView: AVRoutePickerView, context: Context) {
uiView.tintColor = tintColor
uiView.activeTintColor = activeTintColor
}
}
#else
struct AirPlayPickerButton: NSViewRepresentable {
func makeNSView(context: Context) -> AVRoutePickerView {
AVRoutePickerView()
}
func updateNSView(_ nsView: AVRoutePickerView, context: Context) {}
}
#endif

View File

@@ -3,20 +3,21 @@ import SwiftUI
struct ContentView: View {
@Environment(AppState.self) private var app
@Environment(\.scenePhase) private var scenePhase
#if os(iOS)
@State private var splashVisible = true
#endif
/// Bootstrap fertig Signal an den Splash, Phase 2 zu starten.
@State private var isReadyToDismiss = false
/// Splash hat seine Fade-Phase ausgespielt und kann aus dem View-Tree raus.
@State private var splashFinished = false
var body: some View {
ZStack {
mainContent
#if os(iOS)
if splashVisible {
SplashView()
.zIndex(10)
.transition(.opacity.animation(.easeOut(duration: 0.55)))
if !splashFinished {
SplashScreenView(isReady: isReadyToDismiss) {
splashFinished = true
}
.zIndex(10)
}
#endif
}
.task { await boot() }
.onChange(of: scenePhase) { _, newPhase in
@@ -42,17 +43,11 @@ struct ContentView: View {
}
private func boot() async {
#if os(iOS)
// Run bootstrap and minimum splash time in parallel;
// dismiss splash only after BOTH complete.
await withTaskGroup(of: Void.self) { group in
group.addTask { await app.bootstrap() }
group.addTask { try? await Task.sleep(for: .seconds(1.2)) }
await group.waitForAll()
}
withAnimation { splashVisible = false }
#else
await app.bootstrap()
#endif
// SplashScreenView wartet selbst auf seine Mindest-Loop-Zeit, daher
// kein künstlicher Sleep mehr hier nötig. Sobald isReadyToDismiss
// gesetzt wird, fängt der Splash mit Phase 2 an und ruft am Ende
// seinen onComplete-Callback (splashFinished = true).
isReadyToDismiss = true
}
}

View File

@@ -0,0 +1,368 @@
import SwiftUI
/// Default landing screen showing "continue listening" rows, Netflix-style.
/// Data comes from `AppState.progressCache` (sorted by `updatedAt`) joined
/// with `AppState.recentItemsCache` for the cover/title/author metadata.
/// A row is only shown when the user actually has a library of that media
/// type pure audiobook users don't see an empty "Podcasts" row and vice versa.
struct HomeView: View {
@Environment(AppState.self) private var app
let libraries: [Library]
/// Shared with the library views via the same `libraryLayout` AppStorage
/// key toggling grid/list in the toolbar affects both Home and Library.
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
private var layout: LibraryLayout { LibraryLayout(rawValue: layoutRaw) ?? .grid }
private var hasBookLibrary: Bool {
libraries.contains { ($0.mediaType ?? "book") != "podcast" }
}
private var hasPodcastLibrary: Bool {
libraries.contains { $0.mediaType == "podcast" }
}
/// Anything at 95 % or above is considered "done with the content"
/// only credits left. Treats the item as finished even when the server
/// hasn't flipped `isFinished` yet.
private static let nearEndFraction: Double = 0.95
/// Minimum playback position to count as "actually started". Filters out
/// items the user only previewed for a few seconds or where the server
/// reset progress without removing the entry.
private static let minStartedSeconds: Double = 30
private func isStillListening(_ p: PlaybackProgress) -> Bool {
if p.isFinished { return false }
if p.currentTime < Self.minStartedSeconds { return false }
guard p.duration > 0 else { return true } // unknown duration keep showing
return (p.currentTime / p.duration) < Self.nearEndFraction
}
private var audiobooksRecent: [LibraryItem] {
app.progressCache.values
.filter { isStillListening($0) && $0.episodeId == nil }
.sorted { $0.updatedAt > $1.updatedAt }
.prefix(20)
.compactMap { app.recentItemsCache[$0.syncKey] }
}
private var podcastsRecent: [LibraryItem] {
app.progressCache.values
.filter { isStillListening($0) && $0.episodeId != nil }
.sorted { $0.updatedAt > $1.updatedAt }
.prefix(20)
.compactMap { app.recentItemsCache[$0.syncKey] }
}
private var anyContent: Bool {
(hasBookLibrary && !audiobooksRecent.isEmpty) || (hasPodcastLibrary && !podcastsRecent.isEmpty)
}
var body: some View {
ScrollView {
if anyContent {
VStack(alignment: .leading, spacing: 28) {
if hasPodcastLibrary && !podcastsRecent.isEmpty {
sectionHeader("Zuletzt gehörte Podcasts")
sectionBody(items: podcastsRecent)
}
if hasBookLibrary && !audiobooksRecent.isEmpty {
sectionHeader("Zuletzt gehörte Hörbücher")
sectionBody(items: audiobooksRecent)
}
}
.padding(.vertical, 16)
} else {
ContentUnavailableView(
"Noch nichts gehört",
systemImage: "headphones",
description: Text("Sobald du ein Hörbuch oder einen Podcast startest, erscheinen die zuletzt gehörten Folgen hier.")
)
.frame(maxWidth: .infinity, minHeight: 400)
}
}
#if os(iOS)
.refreshable {
await app.refreshProgressCache()
await app.fillRecentItemsCache()
}
#endif
.task(id: app.progressCache.count) {
await app.fillRecentItemsCache()
}
}
@ViewBuilder
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.title2.bold())
.padding(.horizontal, 20)
}
@ViewBuilder
private func sectionBody(items: [LibraryItem]) -> some View {
switch layout {
case .grid:
cardRow(items: items)
case .list:
listRows(items: items)
}
}
@ViewBuilder
private func cardRow(items: [LibraryItem]) -> some View {
HomeCardRow(items: items) { tapped in
Task { await app.playFromRecents(tapped) }
}
}
@ViewBuilder
private func listRows(items: [LibraryItem]) -> some View {
// Reuses LibraryListRow so the Home list rows inherit the listened +
// download indicators automatically. Tapping calls playFromRecents
// (which handles podcasts correctly by looking up the episode), not
// the library's default play() path.
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.syncKey) { idx, item in
LibraryListRow(item: item, dimDownloading: false)
.contentShape(Rectangle())
.onTapGesture {
Task { await app.playFromRecents(item) }
}
#if os(iOS)
.padding(.horizontal, 16)
.padding(.vertical, 8)
#endif
if idx < items.count - 1 {
Divider()
#if os(macOS)
.padding(.leading, 76)
#else
.padding(.leading, 84)
#endif
}
}
}
#if os(macOS)
.padding(.horizontal, 4)
#endif
}
}
/// A single card on the home screen: cover + progress bar + title + author.
/// Sized to roughly match the library grid covers so the visual rhythm is
/// consistent across screens.
struct HomeMediaCard: View {
@Environment(AppState.self) private var app
let item: LibraryItem
let onTap: () -> Void
#if os(iOS)
private let cardWidth: CGFloat = 150
#else
private let cardWidth: CGFloat = 190
#endif
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottom) {
cover
.overlay(alignment: .topTrailing) {
if isListened {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
.shadow(radius: 2)
.padding(4)
}
}
.overlay(alignment: .bottomTrailing) {
downloadBadge.padding(.trailing, 4).padding(.bottom, 10)
}
if !isListened {
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
.padding(.horizontal, 3)
.padding(.bottom, 3)
}
}
Text(item.title)
.font(.subheadline.bold())
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
Text(item.author)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(width: cardWidth)
}
.buttonStyle(.plain)
.contextMenu { mediaItemContextMenu(for: item, app: app) }
}
private var isListened: Bool {
app.progressCache[item.syncKey]?.isFinished ?? false
}
@ViewBuilder
private var downloadBadge: some View {
let state = app.downloads.state(for: item.downloadKey)
switch state {
case .downloaded:
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
.shadow(radius: 2)
case .downloading(let p):
DownloadProgressRing(progress: p)
#if os(iOS)
.frame(width: 22, height: 22)
#else
.frame(width: 26, height: 26)
#endif
case .failed:
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.white, .red)
.font(.title3)
.shadow(radius: 2)
case .notDownloaded:
EmptyView()
}
}
private var cover: some View {
Rectangle()
#if os(iOS)
.fill(Color(.systemGray6))
#else
.fill(AnyShapeStyle(.quaternary))
#endif
// Explicit square frame without this the Rectangle inside a
// LazyHStack stretches to fill the available vertical space and
// pushes siblings way off to the right.
.frame(width: cardWidth, height: cardWidth)
.overlay {
if let url = app.client.coverURL(itemId: item.id) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable().scaledToFit()
case .empty:
ProgressView()
#if os(macOS)
.controlSize(.small)
#endif
case .failure:
Image(systemName: "book.closed")
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
} else {
Image(systemName: "book.closed")
.foregroundStyle(.secondary)
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
/// Horizontally scrollable card row that measures its own content vs. the
/// available viewport. Scrolling is only enabled when items actually overflow,
/// and chevron buttons appear on macOS hover so the user knows there's more
/// off-screen and can page through it without trackpad/scrollwheel.
private struct HomeCardRow: View {
let items: [LibraryItem]
let onTap: (LibraryItem) -> Void
#if os(iOS)
private let cardWidth: CGFloat = 150
#else
private let cardWidth: CGFloat = 190
#endif
private let spacing: CGFloat = 14
private let horizontalPadding: CGFloat = 20
@State private var viewportWidth: CGFloat = 0
@State private var scrollIndex: Int = 0
#if os(macOS)
@State private var isHovering = false
#endif
private var contentWidth: CGFloat {
guard !items.isEmpty else { return 0 }
return CGFloat(items.count) * cardWidth
+ CGFloat(items.count - 1) * spacing
+ horizontalPadding * 2
}
private var canScroll: Bool { contentWidth > viewportWidth + 1 }
var body: some View {
GeometryReader { geo in
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: spacing) {
ForEach(items, id: \.syncKey) { item in
HomeMediaCard(item: item) { onTap(item) }
.id(item.syncKey)
}
}
.padding(.horizontal, horizontalPadding)
}
.scrollDisabled(!canScroll)
.onAppear { viewportWidth = geo.size.width }
.onChange(of: geo.size.width) { _, newValue in viewportWidth = newValue }
#if os(macOS)
.overlay(alignment: .leading) {
chevron(systemName: "chevron.left", visible: isHovering && canScroll) {
scrollBy(-1, proxy: proxy)
}
}
.overlay(alignment: .trailing) {
chevron(systemName: "chevron.right", visible: isHovering && canScroll) {
scrollBy(1, proxy: proxy)
}
}
.onHover { isHovering = $0 }
#endif
}
}
// GeometryReader has no intrinsic size give the row an explicit height
// matching cardWidth (square cover) + ~50pt for title/author.
.frame(height: cardWidth + 52)
}
#if os(macOS)
@ViewBuilder
private func chevron(systemName: String, visible: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: systemName)
.font(.title3.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 36, height: 36)
.background(Circle().fill(.black.opacity(0.55)))
.shadow(color: .black.opacity(0.3), radius: 4, y: 2)
}
.buttonStyle(.plain)
.padding(.horizontal, 8)
.opacity(visible ? 1 : 0)
.animation(.easeInOut(duration: 0.15), value: visible)
}
/// Pages by roughly one viewport in the requested direction. The actual
/// scroll position is tracked via `scrollIndex` the index of the card we
/// want pinned to the leading edge after the scroll.
private func scrollBy(_ direction: Int, proxy: ScrollViewProxy) {
let cardsPerView = max(1, Int(viewportWidth / (cardWidth + spacing)))
let next = max(0, min(items.count - 1, scrollIndex + direction * cardsPerView))
scrollIndex = next
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(items[next].syncKey, anchor: .leading)
}
}
#endif
}

View File

@@ -22,7 +22,11 @@ struct LibraryGridView: View {
private var gridContent: some View {
ScrollView {
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
ForEach(items) { item in
// Identify by syncKey, not by `id`: a synthetic episode item
// shares the podcast container's `id`, so the default
// `Identifiable`-based ForEach would collide if both appear in
// a single search result list.
ForEach(items, id: \.syncKey) { item in
LibraryItemCell(item: item, dimDownloading: dimDownloading)
.onTapGesture { onSelect(item) }
}

View File

@@ -8,21 +8,28 @@ struct LibraryItemCell: View {
var body: some View {
VStack(alignment: .leading, spacing: 2) {
ZStack(alignment: .bottom) {
ZStack(alignment: .topTrailing) {
cover
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
if !(dimDownloading && isActivelyDownloading) {
downloadBadge.padding(4)
cover
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
.overlay(alignment: .topTrailing) {
if isListened {
listenedBadge.padding(4)
}
}
}
.overlay {
if dimDownloading, case .downloading(let p) = app.downloads.state(for: item.downloadKey) {
LargeDownloadOverlay(progress: p)
.overlay(alignment: .bottomTrailing) {
if !(dimDownloading && isActivelyDownloading) {
downloadBadge.padding(.trailing, 4).padding(.bottom, 10)
}
}
.overlay {
if dimDownloading, case .downloading(let p) = app.downloads.state(for: item.downloadKey) {
LargeDownloadOverlay(progress: p)
}
}
if !isListened {
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
.padding(.horizontal, 3)
.padding(.bottom, 3)
}
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
.padding(.horizontal, 3)
.padding(.bottom, 3)
}
Text(item.title)
#if os(iOS)
@@ -56,7 +63,7 @@ struct LibraryItemCell: View {
}
// Ensure the cell fills its full grid column width
.frame(maxWidth: .infinity, alignment: .leading)
.contextMenu { downloadMenuItems }
.contextMenu { mediaItemContextMenu(for: item, app: app) }
}
private var isActivelyDownloading: Bool {
@@ -64,6 +71,14 @@ struct LibraryItemCell: View {
return false
}
/// "Already listened" server's authoritative finished flag. The
/// Audiobookshelf server flips this once `markAsFinishedPercentComplete`
/// (set per library by the admin) is crossed, so we don't need our own
/// threshold here.
private var isListened: Bool {
app.progressCache[item.syncKey]?.isFinished ?? false
}
// MARK: - Cover
private var cover: some View {
@@ -122,23 +137,37 @@ struct LibraryItemCell: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
}
// MARK: - Download badge
// MARK: - Badges
/// Top-right corner: green checkmark if the server has marked this item
/// as finished. Surfaces the "I've listened to this" status that used to
/// be tangled with the download checkmark.
@ViewBuilder
private var listenedBadge: some View {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
.shadow(radius: 2)
}
/// Bottom-right corner: download status. Was previously a checkmark in the
/// top-right; the checkmark now signals "listened", and the download icon
/// is a downward arrow so the two states are visually unambiguous.
@ViewBuilder
private var downloadBadge: some View {
let state = app.downloads.state(for: item.downloadKey)
switch state {
case .downloaded:
Image(systemName: "checkmark.circle.fill")
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
.shadow(radius: 2)
case .downloading(let p):
DownloadProgressRing(progress: p)
#if os(iOS)
.frame(width: 26, height: 26)
.frame(width: 22, height: 22)
#else
.frame(width: 32, height: 32)
.frame(width: 26, height: 26)
#endif
case .failed:
Image(systemName: "exclamationmark.circle.fill")
@@ -150,37 +179,6 @@ struct LibraryItemCell: View {
}
}
// MARK: - Context menu
@ViewBuilder
private var downloadMenuItems: some View {
let key = item.downloadKey
let state = app.downloads.state(for: key)
if item.isPodcastContainer {
Text("Episoden zum Download in der Podcast-Ansicht auswählen")
} else {
switch state {
case .notDownloaded, .failed:
Button {
app.downloads.startDownload(item: item)
} label: {
Label("Für Offline herunterladen", systemImage: "arrow.down.circle")
}
case .downloading:
Button {
app.downloads.cancel(downloadKey: key)
} label: {
Label("Download abbrechen", systemImage: "xmark.circle")
}
case .downloaded:
Button(role: .destructive) {
app.downloads.delete(downloadKey: key)
} label: {
Label("Heruntergeladene Dateien löschen", systemImage: "trash")
}
}
}
}
}
// MARK: - Shared components

View File

@@ -18,7 +18,8 @@ struct LibraryListView: View {
var body: some View {
#if os(iOS)
List {
ForEach(items) { item in
// Identify by syncKey see LibraryGridView comment.
ForEach(items, id: \.syncKey) { item in
LibraryListRow(item: item, dimDownloading: dimDownloading)
.contentShape(Rectangle())
.onTapGesture { onSelect(item) }
@@ -32,7 +33,7 @@ struct LibraryListView: View {
#else
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
ForEach(Array(items.enumerated()), id: \.element.syncKey) { idx, item in
LibraryListRow(item: item, dimDownloading: dimDownloading)
.contentShape(Rectangle())
.onTapGesture { onSelect(item) }
@@ -70,7 +71,7 @@ struct LibraryListRow: View {
.foregroundStyle(.secondary)
.lineLimit(1)
let fraction = app.progressFraction(itemId: item.id, episodeId: item.episodeId)
if fraction > 0 {
if fraction > 0 && !isListened {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
@@ -100,6 +101,11 @@ struct LibraryListRow: View {
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
}
#endif
if isListened {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
}
if !(dimDownloading && isActivelyDownloading) {
downloadStatus
#if os(macOS)
@@ -111,7 +117,7 @@ struct LibraryListRow: View {
.padding(.horizontal, 16)
.padding(.vertical, 8)
#endif
.contextMenu { downloadMenuItems }
.contextMenu { mediaItemContextMenu(for: item, app: app) }
}
private var isActivelyDownloading: Bool {
@@ -119,6 +125,10 @@ struct LibraryListRow: View {
return false
}
private var isListened: Bool {
app.progressCache[item.syncKey]?.isFinished ?? false
}
private var cover: some View {
Group {
if let url = app.client.coverURL(itemId: item.id) {
@@ -153,7 +163,9 @@ struct LibraryListRow: View {
let state = app.downloads.state(for: item.downloadKey)
switch state {
case .downloaded:
Image(systemName: "checkmark.circle.fill")
// Down-arrow now signals "downloaded"; the checkmark is reserved
// for the listened indicator that sits next to it.
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
case .downloading(let p):
@@ -172,32 +184,6 @@ struct LibraryListRow: View {
}
}
@ViewBuilder
private var downloadMenuItems: some View {
let key = item.downloadKey
let state = app.downloads.state(for: key)
if item.isPodcastContainer {
Text("Episoden zum Download in der Podcast-Ansicht auswählen")
} else {
switch state {
case .notDownloaded, .failed:
Button { app.downloads.startDownload(item: item) } label: {
Label("Für Offline herunterladen", systemImage: "arrow.down.circle")
}
case .downloading:
Button { app.downloads.cancel(downloadKey: key) } label: {
Label("Download abbrechen", systemImage: "xmark.circle")
}
case .downloaded:
Button(role: .destructive) {
app.downloads.delete(downloadKey: key)
} label: {
Label("Heruntergeladene Dateien löschen", systemImage: "trash")
}
}
}
}
#if os(macOS)
private func formatDuration(_ seconds: Double) -> String {
guard seconds.isFinite, seconds > 0 else { return "" }

View File

@@ -35,7 +35,7 @@ struct LoginView: View {
Image(systemName: "books.vertical.fill")
.font(.system(size: 52, weight: .regular))
.foregroundStyle(.white)
Text("ABS Client")
Text("Nook")
.font(.title.bold())
.foregroundStyle(.white)
Text("Verbinde dich mit deinem Audiobookshelf-Server")
@@ -124,7 +124,7 @@ struct LoginView: View {
Image(systemName: "books.vertical.fill")
.font(.system(size: 48))
.foregroundStyle(.tint)
Text("ABS Client")
Text("Nook")
.font(.largeTitle).bold()
Text("Verbinde dich mit deinem Audiobookshelf-Server")
.foregroundStyle(.secondary)

View File

@@ -1,11 +1,39 @@
import SwiftUI
enum LibraryFilter: Hashable {
case home
case library(String)
case downloaded
case history
}
/// One indexed (podcast, episode) pair used to make episodes searchable from the
/// library overview without having to dive into each show first.
struct PodcastEpisodeMatch: Hashable {
let podcast: LibraryItem
let episode: PodcastEpisode
var syncKey: String { "\(podcast.id)|\(episode.id)" }
/// Synthesizes a `LibraryItem` so the cell/list views can render this
/// episode the same way they render any other item: episode title up top,
/// podcast name as the subtitle, podcast cover as the artwork.
var asLibraryItem: LibraryItem {
var item = LibraryItem(
id: podcast.id,
title: episode.title,
author: podcast.title,
durationSeconds: episode.durationSeconds > 0
? episode.durationSeconds
: episode.audioFile.durationSeconds,
audioFiles: [episode.audioFile]
)
item.mediaType = "podcast"
item.episodeId = episode.id
return item
}
}
@Observable
@MainActor
final class LibraryViewModel {
@@ -15,21 +43,93 @@ final class LibraryViewModel {
var errorMessage: String?
var selection: LibraryFilter?
/// Episode index per library, populated lazily the first time the user
/// types in the search bar of a podcast library. Episodes aren't part of
/// `items` (those only hold podcast containers) so we have to load them
/// explicitly. Kept in memory only cleared whenever the library reloads.
var episodeIndex: [String: [PodcastEpisodeMatch]] = [:]
/// Library IDs currently being indexed. Prevents duplicate in-flight loads
/// when the user types rapidly while the first request is still running.
var episodeIndexLoading: Set<String> = []
var isLoadingEpisodeIndex: Bool {
!episodeIndexLoading.isEmpty
}
func loadLibraries(client: ABSClient) async {
do {
libraries = try await client.fetchLibraries()
if selection == nil, let first = libraries.first {
selection = .library(first.id)
// Home is the new default landing screen first library is only
// used as fallback when there are zero libraries.
if selection == nil {
selection = .home
}
} catch {
errorMessage = error.localizedDescription
}
}
/// Fetches every podcast's episodes in the given library and caches them
/// flat so the search field can match against episode titles too. No-ops
/// when the index is already populated for this library.
func loadEpisodeIndex(libraryId: String, client: ABSClient) async {
guard episodeIndex[libraryId] == nil,
!episodeIndexLoading.contains(libraryId) else { return }
let podcasts = items.filter { $0.isPodcastContainer }
guard !podcasts.isEmpty else {
episodeIndex[libraryId] = []
return
}
episodeIndexLoading.insert(libraryId)
defer { episodeIndexLoading.remove(libraryId) }
var collected: [PodcastEpisodeMatch] = []
var anySuccess = false
for p in podcasts {
// Bail out if the user already navigated away keeps a slow scan
// from blocking later searches.
guard selection == .library(libraryId) else { return }
do {
let (_, eps) = try await client.fetchEpisodes(podcastItemId: p.id)
anySuccess = true
for ep in eps {
collected.append(PodcastEpisodeMatch(podcast: p, episode: ep))
}
} catch {
// Individual podcast fetch errors are ignored the rest of
// the library is still searchable.
}
}
// Bei totalem Offline-Failure (kein einziger Fetch erfolgreich) NICHT
// den Cache mit `[]` setzen sonst wäre der Index "geladen aber leer"
// und ein erneuter Suchversuch würde nichts mehr tun. Nil-lassen
// erlaubt Retry beim nächsten Tippen.
if anySuccess {
episodeIndex[libraryId] = collected
}
}
func episodeMatch(syncKey: String, in libraryId: String) -> PodcastEpisodeMatch? {
episodeIndex[libraryId]?.first(where: { $0.syncKey == syncKey })
}
func loadItems(client: ABSClient, downloads: DownloadManager) async {
guard let selection else { return }
switch selection {
case .home:
// HomeView reads progressCache + recentItemsCache directly; no
// library items to load into vm.items.
items = []
errorMessage = nil
case .library(let id):
// Reload of items invalidates the episode index episodes are
// tied to the previous snapshot of `items`. Auch das Loading-Flag
// löschen damit ein nach-dem-Refresh neu getippter Suchbegriff
// sofort einen Neu-Aufbau triggern kann (eine ggf. noch laufende
// alte loadEpisodeIndex-Schleife läuft zu Ende und das Resultat
// wird vom nächsten Trigger ohnehin überschrieben).
episodeIndex.removeValue(forKey: id)
episodeIndexLoading.remove(id)
do {
items = try await client.fetchItems(libraryId: id)
errorMessage = nil
@@ -77,6 +177,7 @@ struct MainView: View {
@Environment(AppState.self) private var app
@State private var vm = LibraryViewModel()
@State private var navPath: [LibraryItem] = []
@State private var librarySearchText: String = ""
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
@AppStorage("historyEnabled") private var historyEnabled: Bool = false
@State private var showFullHistory: Bool = false
@@ -95,8 +196,19 @@ struct MainView: View {
.task { await loadAll() }
.onChange(of: vm.selection) { _, _ in
navPath.removeAll()
librarySearchText = ""
Task { await loadAll() }
}
.onChange(of: librarySearchText) { _, newValue in
// First keystroke in a podcast library kicks off the episode
// index load (cached after that, so subsequent searches are
// instant). Skipped for audiobook libraries those don't
// have episodes to scan.
guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
currentLibraryIsPodcast,
let libId = currentLibraryId else { return }
Task { await vm.loadEpisodeIndex(libraryId: libId, client: app.client) }
}
.onChange(of: app.downloads.pendingItems.count) { _, _ in
if vm.selection == .downloaded {
Task { await vm.loadItems(client: app.client, downloads: app.downloads) }
@@ -207,18 +319,49 @@ struct MainView: View {
}
private func handleSelect(_ item: LibraryItem) {
if item.isPodcastContainer {
if item.isPodcastEpisode {
// Synthetic episode result from library-wide podcast search.
// Prefer the in-memory index (no extra round-trip); fall back to
// re-fetching via the Home/Recents path if the index expired.
if let libId = currentLibraryId,
let match = vm.episodeMatch(syncKey: item.syncKey, in: libId) {
Task { await app.play(podcast: match.podcast, episode: match.episode) }
} else {
Task { await app.playFromRecents(item) }
}
} else if item.isPodcastContainer {
navPath.append(item)
} else {
Task { await app.play(item: item) }
}
}
private var currentLibraryId: String? {
if case .library(let id) = vm.selection { return id }
return nil
}
private var currentLibraryIsPodcast: Bool {
guard let id = currentLibraryId else { return false }
if let mt = vm.libraries.first(where: { $0.id == id })?.mediaType {
return mt == "podcast"
}
// Fallback: `vm.libraries` ist noch leer (User tippt direkt nach
// App-Start, bevor `loadLibraries()` durch ist). Wir leiten den Typ
// aus den bereits geladenen Items ab sobald die Library Items
// hat die Podcast-Container sind, ist es eine Podcast-Library.
return vm.items.contains { $0.isPodcastContainer }
}
// MARK: - macOS sidebar
#if os(macOS)
private var sidebar: some View {
List(selection: $vm.selection) {
Section {
Label("Home", systemImage: "house.fill")
.tag(LibraryFilter.home)
}
Section(String(localized: "sidebar.libraries")) {
ForEach(vm.libraries) { lib in
Label(lib.name, systemImage: "books.vertical")
@@ -374,17 +517,60 @@ struct MainView: View {
@ViewBuilder
private var detail: some View {
#if os(macOS)
if vm.selection == .history {
if vm.selection == .home {
HomeView(libraries: vm.libraries)
.navigationTitle("Home")
.toolbar {
// Same Kachel/Liste picker the library view has, so the
// shared `libraryLayout` setting can be toggled from Home
// too. Otherwise the user would land here with no UI
// affordance to switch into Listenansicht.
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")
}
}
} else if vm.selection == .history {
historyDetailContent
.navigationTitle(String(localized: "sidebar.history"))
} else {
libraryContent
}
#else
libraryContent
if vm.selection == .home {
HomeView(libraries: vm.libraries)
} else {
libraryContent
}
#endif
}
private var filteredItems: [LibraryItem] {
let q = librarySearchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !q.isEmpty else { return vm.items }
let containerMatches = vm.items.filter { item in
item.title.localizedCaseInsensitiveContains(q)
|| item.author.localizedCaseInsensitiveContains(q)
}
// For podcast libraries, also surface matching episodes from across
// the entire library the user shouldn't have to dive into each show
// first. Episode rows render with the show name as their subtitle.
guard currentLibraryIsPodcast, let libId = currentLibraryId else {
return containerMatches
}
let episodeMatches = (vm.episodeIndex[libId] ?? [])
.filter { $0.episode.title.localizedCaseInsensitiveContains(q) }
.map { $0.asLibraryItem }
return containerMatches + episodeMatches
}
private var libraryContent: some View {
ZStack {
if vm.isLoading && vm.items.isEmpty {
@@ -397,12 +583,54 @@ struct MainView: View {
} 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 if filteredItems.isEmpty {
if vm.isLoadingEpisodeIndex {
VStack(spacing: 10) {
ProgressView()
Text("Folgen werden durchsucht …")
.font(.callout)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ContentUnavailableView.search(text: librarySearchText)
.transition(.opacity)
}
} else {
libraryGridOrList.transition(.opacity)
VStack(spacing: 0) {
if vm.isLoadingEpisodeIndex {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Folgen werden durchsucht …")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
libraryGridOrList
}
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.2), value: vm.isLoading)
.animation(.easeInOut(duration: 0.2), value: vm.items.isEmpty)
.animation(.easeInOut(duration: 0.15), value: filteredItems.isEmpty)
.animation(.easeInOut(duration: 0.15), value: vm.isLoadingEpisodeIndex)
.searchable(text: $librarySearchText, placement: .automatic, prompt: searchPrompt)
}
private var searchPrompt: String {
switch vm.selection {
case .library(let id):
let mediaType = vm.libraries.first(where: { $0.id == id })?.mediaType
return mediaType == "podcast" ? "Podcasts & Folgen durchsuchen" : "Hörbücher durchsuchen"
case .downloaded:
return "Downloads durchsuchen"
default:
return "Suchen"
}
}
@ViewBuilder
@@ -411,9 +639,9 @@ struct MainView: View {
let isDownloaded = vm.selection == .downloaded
switch layout {
case .grid:
LibraryGridView(items: vm.items, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) }
LibraryGridView(items: filteredItems, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) }
case .list:
LibraryListView(items: vm.items, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) }
LibraryListView(items: filteredItems, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) }
}
}
#if os(macOS)
@@ -453,9 +681,11 @@ struct MainView: View {
private var libraryMenu: some View {
Menu {
Picker("Bibliothek", selection: Binding(
get: { vm.selection ?? .library("") },
get: { vm.selection ?? .home },
set: { vm.selection = $0 }
)) {
Label("Home", systemImage: "house.fill")
.tag(LibraryFilter.home)
Section("Bibliotheken") {
ForEach(vm.libraries) { lib in
Label(lib.name, systemImage: "books.vertical")
@@ -494,6 +724,7 @@ struct MainView: View {
private var selectionIcon: String {
switch vm.selection {
case .home: return "house.fill"
case .downloaded: return "arrow.down.circle.fill"
case .history: return "clock.arrow.circlepath"
default: return "books.vertical"
@@ -505,6 +736,8 @@ struct MainView: View {
private var currentTitle: String {
switch vm.selection {
case .home:
return "Home"
case .library(let id):
return vm.libraries.first(where: { $0.id == id })?.name ?? "Bibliothek"
case .downloaded:

View File

@@ -0,0 +1,48 @@
import SwiftUI
/// Unified right-click / long-press menu for any `LibraryItem` shown on the
/// Home, Library, or Heruntergeladen screens. Includes both download actions
/// (start / cancel / delete files) AND a "Fortschritt entfernen" action when
/// the user has any server-side progress on the item.
///
/// The "Fortschritt entfernen" row was previously Home-only; the download
/// rows were previously Library-only combining them in one place means the
/// same menu shows up regardless of which screen the user is on.
@ViewBuilder
func mediaItemContextMenu(for item: LibraryItem, app: AppState) -> some View {
let key = item.downloadKey
let state = app.downloads.state(for: key)
if item.isPodcastContainer {
Text("Episoden zum Download in der Podcast-Ansicht auswählen")
} else {
switch state {
case .notDownloaded, .failed:
Button {
app.downloads.startDownload(item: item)
} label: {
Label("Für Offline herunterladen", systemImage: "arrow.down.circle")
}
case .downloading:
Button {
app.downloads.cancel(downloadKey: key)
} label: {
Label("Download abbrechen", systemImage: "xmark.circle")
}
case .downloaded:
Button(role: .destructive) {
app.downloads.delete(downloadKey: key)
} label: {
Label("Heruntergeladene Dateien löschen", systemImage: "trash")
}
}
}
if app.progressCache[item.syncKey] != nil {
Divider()
Button(role: .destructive) {
Task { await app.removeProgress(itemId: item.id, episodeId: item.episodeId) }
} label: {
Label("Fortschritt entfernen", systemImage: "arrow.uturn.backward.circle")
}
}
}

View File

@@ -114,6 +114,10 @@ struct PlayerBar: View {
rateMenu
AirPlayPickerButton()
.frame(width: 26, height: 26)
.disabled(!app.player.isReady)
Button {
app.stopPlayback()
} label: {
@@ -150,6 +154,11 @@ struct PlayerBar: View {
sleepMenu
AirPlayPickerButton()
.frame(width: 24, height: 24)
.disabled(!app.player.isReady)
.help("Auf anderem Gerät abspielen (AirPlay)")
Spacer(minLength: 0)
if historyEnabled {

View File

@@ -8,6 +8,21 @@ struct PodcastDetailView: View {
@State private var podcastDetail: LibraryItem?
@State private var isLoading: Bool = true
@State private var errorMessage: String?
@State private var searchText: String = ""
/// Bevorzugt die nach `fetchEpisodes` aufgefrischte Detail-Version
/// des Podcasts (mit Cover, Description etc.), fällt auf den initial
/// übergebenen Container zurück solange der Fetch noch läuft oder
/// fehlgeschlagen ist.
private var resolvedPodcast: LibraryItem { podcastDetail ?? podcast }
private var filteredEpisodes: [PodcastEpisode] {
let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !q.isEmpty else { return episodes }
return episodes.filter { ep in
ep.title.localizedCaseInsensitiveContains(q)
}
}
var body: some View {
VStack(spacing: 0) {
@@ -15,10 +30,11 @@ struct PodcastDetailView: View {
Divider()
content
}
.navigationTitle(podcastDetail?.title ?? podcast.title)
.navigationTitle(resolvedPodcast.title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.searchable(text: $searchText, placement: .automatic, prompt: "Folge suchen")
.task { await load() }
}
@@ -59,14 +75,16 @@ struct PodcastDetailView: View {
ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err))
} else if episodes.isEmpty {
ContentUnavailableView("Keine Folgen", systemImage: "music.note.list", description: Text("Dieser Podcast enthält noch keine Folgen."))
} else if filteredEpisodes.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
#if os(iOS)
List {
ForEach(episodes, id: \.id) { ep in
EpisodeRow(podcast: podcastDetail ?? podcast, episode: ep)
ForEach(filteredEpisodes, id: \.id) { ep in
EpisodeRow(podcast: resolvedPodcast, episode: ep)
.contentShape(Rectangle())
.onTapGesture {
Task { await app.play(podcast: podcastDetail ?? podcast, episode: ep) }
Task { await app.play(podcast: resolvedPodcast, episode: ep) }
}
}
}
@@ -74,13 +92,13 @@ struct PodcastDetailView: View {
#else
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(episodes.enumerated()), id: \.element.id) { idx, ep in
EpisodeRow(podcast: podcastDetail ?? podcast, episode: ep)
ForEach(Array(filteredEpisodes.enumerated()), id: \.element.id) { idx, ep in
EpisodeRow(podcast: resolvedPodcast, episode: ep)
.contentShape(Rectangle())
.onTapGesture {
Task { await app.play(podcast: podcastDetail ?? podcast, episode: ep) }
Task { await app.play(podcast: resolvedPodcast, episode: ep) }
}
if idx < episodes.count - 1 {
if idx < filteredEpisodes.count - 1 {
Divider().padding(.leading, 16)
}
}
@@ -122,6 +140,10 @@ private struct EpisodeRow: View {
return item
}
private var isListened: Bool {
app.progressCache[syntheticItem.syncKey]?.isFinished ?? false
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "play.circle.fill")
@@ -163,7 +185,7 @@ private struct EpisodeRow: View {
#endif
}
let frac = app.progressFraction(itemId: podcast.id, episodeId: episode.id)
if frac > 0 {
if frac > 0 && !isListened {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
@@ -179,6 +201,14 @@ private struct EpisodeRow: View {
}
}
Spacer(minLength: 0)
if isListened {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
#if os(macOS)
.padding(.top, 4)
#endif
}
downloadButton
#if os(macOS)
.frame(width: 32)
@@ -191,7 +221,7 @@ private struct EpisodeRow: View {
#else
.padding(.vertical, 4)
#endif
.contextMenu { contextMenuItems }
.contextMenu { mediaItemContextMenu(for: syntheticItem, app: app) }
}
@ViewBuilder
@@ -216,7 +246,9 @@ private struct EpisodeRow: View {
.frame(width: 22, height: 22)
.onTapGesture { app.downloads.cancel(downloadKey: key) }
case .downloaded:
Image(systemName: "checkmark.circle.fill")
// Down-arrow = downloaded; checkmark is reserved for the listened
// indicator that may sit next to this in the row trailing area.
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
case .failed(let msg):
@@ -232,32 +264,6 @@ private struct EpisodeRow: View {
}
}
@ViewBuilder
private var contextMenuItems: some View {
let key = syntheticItem.downloadKey
let state = app.downloads.state(for: key)
switch state {
case .notDownloaded, .failed:
Button {
app.downloads.startDownload(item: syntheticItem)
} label: {
Label("Folge herunterladen", systemImage: "arrow.down.circle")
}
case .downloading:
Button {
app.downloads.cancel(downloadKey: key)
} label: {
Label("Download abbrechen", systemImage: "xmark.circle")
}
case .downloaded:
Button(role: .destructive) {
app.downloads.delete(downloadKey: key)
} label: {
Label("Heruntergeladene Folge löschen", systemImage: "trash")
}
}
}
private func formatDuration(_ seconds: Double) -> String {
guard seconds.isFinite, seconds > 0 else { return "" }
let total = Int(seconds)

View File

@@ -1,95 +1,306 @@
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
struct SplashView: View {
@State private var appeared = false
// MARK: - App-Icon-Loading (cross-platform, kein Extra-Asset)
extension Image {
/// Lädt das echte AppIcon zur Laufzeit:
/// iOS via `CFBundleIcons` aus der Info.plist, macOS via
/// `NSApp.applicationIconImage`. Fällt auf ein SF-Symbol zurück wenn die
/// Plattform-spezifische Auflösung leer ist (z.B. Preview ohne Bundle).
static var appIcon: Image {
#if os(iOS)
if let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any],
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
let files = primary["CFBundleIconFiles"] as? [String],
let name = files.last,
let ui = UIImage(named: name) {
return Image(uiImage: ui)
}
return Image(systemName: "books.vertical.fill")
#else
if let ns = NSApp?.applicationIconImage {
return Image(nsImage: ns)
}
return Image(systemName: "books.vertical.fill")
#endif
}
}
// MARK: - Color-Helper für hellere/dunklere Akzent-Töne
private extension Color {
func lighter(by amount: CGFloat = 0.2) -> Color { adjustBrightness(by: amount) }
func darker(by amount: CGFloat = 0.2) -> Color { adjustBrightness(by: -amount) }
private func adjustBrightness(by amount: CGFloat) -> Color {
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
#if os(iOS)
UIColor(self).getHue(&h, saturation: &s, brightness: &b, alpha: &a)
#else
(NSColor(self).usingColorSpace(.sRGB) ?? NSColor(self))
.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
#endif
return Color(hue: Double(h),
saturation: Double(s),
brightness: Double(min(1.0, max(0.0, b + amount))),
opacity: Double(a))
}
}
// MARK: - Partikel-Konfiguration
private struct ParticleConfig: Identifiable {
let id: Int
let startAngle: Double // 02π Position auf der Umlaufbahn bei t=0
let orbitRadius: CGFloat // 80150pt Radius der Umlaufbahn
let size: CGFloat // 4 / 7 / 10pt
let appearDelay: Double // 00.9s Stagger-Fade-In
let angularVelocity: Double // rad/s Umlaufgeschwindigkeit, Vorzeichen = Richtung
let radialWobble: CGFloat // 05pt sanftes Atmen des Radius
let wobbleFrequency: Double // 0.40.8 Hz
let wobblePhase: Double // 02π
let shade: ParticleShade
}
private enum ParticleShade {
case light, accent, dark
func color(base: Color) -> Color {
switch self {
case .light: return base.lighter(by: 0.25)
case .accent: return base
case .dark: return base.darker(by: 0.20)
}
}
}
/// Acht Partikel auf unterschiedlichen Umlaufbahnen. Reproduzierbar bei
/// jedem App-Start (kein Random). Drei Bahnen-Schichten (innen/mitte/außen)
/// plus gemischte Drehrichtungen (Vorzeichen von `angularVelocity`) damit
/// das Bild nicht wie ein gleichförmiges Karussell wirkt.
private let particleConfigs: [ParticleConfig] = [
.init(id: 0, startAngle: 0.45, orbitRadius: 88, size: 7, appearDelay: 0.05, angularVelocity: 0.32, radialWobble: 3, wobbleFrequency: 0.7, wobblePhase: 0.3, shade: .light),
.init(id: 1, startAngle: 1.20, orbitRadius: 130, size: 4, appearDelay: 0.30, angularVelocity: -0.22, radialWobble: 4, wobbleFrequency: 0.5, wobblePhase: 1.1, shade: .accent),
.init(id: 2, startAngle: 2.00, orbitRadius: 85, size: 10, appearDelay: 0.55, angularVelocity: 0.18, radialWobble: 2, wobbleFrequency: 0.4, wobblePhase: 2.4, shade: .dark),
.init(id: 3, startAngle: 2.80, orbitRadius: 115, size: 7, appearDelay: 0.20, angularVelocity: 0.28, radialWobble: 5, wobbleFrequency: 0.6, wobblePhase: 0.8, shade: .accent),
.init(id: 4, startAngle: 3.65, orbitRadius: 95, size: 4, appearDelay: 0.70, angularVelocity: -0.35, radialWobble: 3, wobbleFrequency: 0.55, wobblePhase: 3.2, shade: .light),
.init(id: 5, startAngle: 4.40, orbitRadius: 140, size: 10, appearDelay: 0.10, angularVelocity: -0.16, radialWobble: 4, wobbleFrequency: 0.35, wobblePhase: 4.0, shade: .dark),
.init(id: 6, startAngle: 5.20, orbitRadius: 100, size: 7, appearDelay: 0.45, angularVelocity: 0.24, radialWobble: 3, wobbleFrequency: 0.65, wobblePhase: 5.1, shade: .light),
.init(id: 7, startAngle: 5.80, orbitRadius: 120, size: 4, appearDelay: 0.85, angularVelocity: -0.30, radialWobble: 5, wobbleFrequency: 0.45, wobblePhase: 1.6, shade: .accent),
]
// MARK: - SplashScreenView
/// Vierphasiger Splash:
/// 1. Partikel erscheinen gestaffelt um das Icon (~1.2s)
/// 2. Partikel driften organisch in Loop bis `isReady` true wird (min 0.5s)
/// 3. Alle Partikel werden ins Icon gesogen (0.6s) mit kleinem Icon-Wobble
/// 4. Flash-Ring + Icon-Scale-Pulse (0.4s, iOS auch Haptic)
/// 5. Fade-Out (0.4s) `onComplete()`
struct SplashScreenView: View {
let isReady: Bool
let onComplete: () -> Void
private enum Phase { case appearing, idle, attracting, flash, fading }
@State private var phase: Phase = .appearing
@State private var particlesShown: Bool = false
@State private var particlesAttracted: Bool = false
@State private var iconScale: CGFloat = 1.0
@State private var iconWobble: CGFloat = 1.0
@State private var iconBrightness: Double = 0
@State private var ringScale: CGFloat = 1.0
@State private var ringOpacity: Double = 0
@State private var splashOpacity: Double = 1.0
/// Spiegel von `isReady` mit `@State`-Storage. Nötig weil die `.task`-
/// Closure die View-Struct beim ersten Erscheinen capturet und das
/// `let isReady` aus dem alten Snapshot liest das bleibt forever false
/// auch wenn der Parent neu rendert. `@State`-Storage dagegen ist
/// shared über Reconstructions hinweg, also sieht der laufende Task die
/// Aktualisierung sobald `onChange` sie hier reinpiped.
@State private var readySignaled: Bool = false
/// Einmalig festgelegt Basis für die TimelineView-Zeit. Als `@State`,
/// damit der Wert bei View-Re-Renders aus ContentView nicht versehentlich
/// auf jetzt" zurückgesetzt wird und die Drift-Bewegung dadurch springt.
@State private var referenceDate: Date = .init()
var body: some View {
ZStack {
#if os(iOS)
Color(.systemBackground).ignoresSafeArea()
#else
Color(NSColor.windowBackgroundColor).ignoresSafeArea()
#endif
VStack(spacing: 36) {
// Animated icon
ZStack {
// Outer glow pulse
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 180, height: 180)
.scaleEffect(appeared ? 1.0 : 0.2)
.blur(radius: appeared ? 12 : 40)
.animation(.easeOut(duration: 1.1), value: appeared)
// Ring border
Circle()
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
.frame(width: 130, height: 130)
.scaleEffect(appeared ? 1.0 : 0.4)
.opacity(appeared ? 1 : 0)
.animation(.easeOut(duration: 0.8).delay(0.1), value: appeared)
// Book icon springs into place
Image(systemName: "books.vertical.fill")
.font(.system(size: 58, weight: .regular))
.foregroundStyle(Color.accentColor)
.scaleEffect(appeared ? 1.0 : 0.1)
.opacity(appeared ? 1 : 0)
.animation(.spring(duration: 0.65, bounce: 0.55), value: appeared)
.symbolEffect(.pulse.byLayer,
options: .speed(0.5).repeating,
value: appeared)
}
// Text
VStack(spacing: 6) {
Text("ABS Client")
.font(.title2.bold())
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 18)
.animation(.easeOut(duration: 0.5).delay(0.28), value: appeared)
Text("Audiobookshelf")
.font(.subheadline)
.foregroundStyle(.secondary)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 10)
.animation(.easeOut(duration: 0.45).delay(0.42), value: appeared)
}
}
// Loading dots at bottom
VStack {
Spacer()
LoadingDots()
.opacity(appeared ? 1 : 0)
.animation(.easeIn(duration: 0.3).delay(0.65), value: appeared)
.padding(.bottom, 60)
}
}
.onAppear { appeared = true }
}
}
private struct LoadingDots: View {
@State private var phase: Int = 0
var body: some View {
HStack(spacing: 7) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(Color.accentColor.opacity(phase == i ? 0.9 : 0.3))
.frame(width: 7, height: 7)
.scaleEffect(phase == i ? 1.25 : 1.0)
.animation(.easeInOut(duration: 0.35), value: phase)
background
ZStack {
particleLayer
iconLayer
flashRing
}
.frame(width: 320, height: 320)
}
.opacity(splashOpacity)
.task { await runSplashAnimation() }
.onAppear {
Timer.scheduledTimer(withTimeInterval: 0.38, repeats: true) { _ in
phase = (phase + 1) % 3
// Falls der Parent isReady bereits beim ersten Render gesetzt hat
// (z.B. weil bootstrap super-schnell durchlief), spiegeln wir den
// Anfangswert direkt ohne dass eine onChange-Flanke nötig wäre.
if isReady { readySignaled = true }
}
.onChange(of: isReady) { _, newValue in
if newValue { readySignaled = true }
}
}
// MARK: Background
@ViewBuilder
private var background: some View {
#if os(iOS)
Color(.systemBackground).ignoresSafeArea()
#else
Color(NSColor.windowBackgroundColor).ignoresSafeArea()
#endif
}
// MARK: Icon
private var iconLayer: some View {
Image.appIcon
.resizable()
.frame(width: 96, height: 96)
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 8, y: 3)
.scaleEffect(iconScale * iconWobble)
.brightness(iconBrightness)
}
// MARK: Flash-Ring
private var flashRing: some View {
Circle()
.stroke(Color.white, lineWidth: 4)
.frame(width: 96, height: 96)
.scaleEffect(ringScale)
.opacity(ringOpacity)
.allowsHitTesting(false)
}
// MARK: Partikel
@ViewBuilder
private var particleLayer: some View {
// TimelineView läuft während `.appearing` und `.idle`, damit die
// Partikel schon beim Stagger-Fade-In leicht driften (sonst gäb's
// einen sichtbaren Positions-Sprung beim Übergang zu `.idle`).
// Pausiert ab `.attracting` die gefrorene Position dient als
// Startpunkt für die Attract-Animation Richtung .zero.
let paused = (phase == .attracting || phase == .flash || phase == .fading)
TimelineView(.animation(paused: paused)) { context in
let t = context.date.timeIntervalSince(referenceDate)
ZStack {
ForEach(particleConfigs) { cfg in
particleView(cfg: cfg, t: t)
}
}
}
}
@ViewBuilder
private func particleView(cfg: ParticleConfig, t: TimeInterval) -> some View {
let drifted = idlePosition(for: cfg, t: t)
let position = particlesAttracted ? CGPoint.zero : drifted
let scale: CGFloat = particlesAttracted ? 0.0 : 1.0
// Wichtig: NICHT `Color.accentColor` (semantisch konvertiert via
// UIColor unzuverlässig zum echten Asset-Wert, gibt manchmal den
// System-Tint = blau zurück). Direkt aus dem Asset-Catalog lesen.
Circle()
.fill(cfg.shade.color(base: Color("AccentColor")))
.frame(width: cfg.size, height: cfg.size)
.opacity(particlesShown ? 1.0 : 0.0)
.scaleEffect(scale)
.offset(x: position.x, y: position.y)
// Stagger-Fade-In: jeder Partikel mit eigener Verzögerung
.animation(.easeOut(duration: 0.5).delay(cfg.appearDelay), value: particlesShown)
// Attract: 0.6s easeIn ins Zentrum, gleichzeitig auf scale=0
.animation(.easeIn(duration: 0.6), value: particlesAttracted)
}
private func idlePosition(for cfg: ParticleConfig, t: TimeInterval) -> CGPoint {
// Echte Umlaufbahn: Winkel wächst mit t (Vorzeichen = Drehrichtung).
// Radius "atmet" leicht über sinusförmiges radialWobble damit die
// Bahn nicht maschinell perfekt-kreisförmig wirkt.
let angle = cfg.startAngle + t * cfg.angularVelocity
let wobble = sin(t * cfg.wobbleFrequency + cfg.wobblePhase) * Double(cfg.radialWobble)
let radius = Double(cfg.orbitRadius) + wobble
return CGPoint(x: cos(angle) * radius, y: sin(angle) * radius)
}
// MARK: Animation-Orchestrierung
private func runSplashAnimation() async {
// Phase 1a Partikel erscheinen gestaffelt
particlesShown = true
// Warten bis alle Partikel sichtbar sind (max appearDelay 0.85 + fade 0.5)
try? await Task.sleep(for: .milliseconds(1400))
if Task.isCancelled { return }
phase = .idle
// Phase 1b Drift-Loop bis readySignaled UND min 500ms in idle.
// Wichtig: NICHT `isReady` lesen siehe Doc-Kommentar an readySignaled.
let idleStart = Date()
let minIdleDuration: TimeInterval = 0.5
while !readySignaled || Date().timeIntervalSince(idleStart) < minIdleDuration {
try? await Task.sleep(for: .milliseconds(80))
if Task.isCancelled { return }
}
// Phase 2 Sog ins Icon-Zentrum
phase = .attracting
withAnimation(.easeIn(duration: 0.6)) {
particlesAttracted = true
}
// Bei ~400ms in die Attract-Phase ein kleiner Icon-Wobble als die
// Partikel ankommen fühlt sich an wie der Impact.
try? await Task.sleep(for: .milliseconds(400))
if Task.isCancelled { return }
withAnimation(.easeOut(duration: 0.1)) { iconWobble = 1.06 }
try? await Task.sleep(for: .milliseconds(100))
withAnimation(.easeIn(duration: 0.1)) { iconWobble = 1.0 }
try? await Task.sleep(for: .milliseconds(100))
if Task.isCancelled { return }
// Phase 3 Flash + Scale-Pulse + Haptic
phase = .flash
#if os(iOS)
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
#endif
// Ring: 1.0 2.5×, opacity 1 0 (0.4s easeOut)
ringScale = 1.0
ringOpacity = 1.0
withAnimation(.easeOut(duration: 0.4)) {
ringScale = 2.5
ringOpacity = 0
}
// Icon-Brightness: schnell hoch, langsamer runter
withAnimation(.easeOut(duration: 0.15)) { iconBrightness = 0.6 }
// Parallel: Icon-Scale-Pulse 1.0 1.15 1.0
withAnimation(.easeOut(duration: 0.2)) { iconScale = 1.15 }
try? await Task.sleep(for: .milliseconds(200))
if Task.isCancelled { return }
withAnimation(.easeIn(duration: 0.25)) { iconBrightness = 0 }
withAnimation(.easeIn(duration: 0.2)) { iconScale = 1.0 }
try? await Task.sleep(for: .milliseconds(200))
if Task.isCancelled { return }
// Phase 4 Fade
phase = .fading
withAnimation(.easeOut(duration: 0.4)) {
splashOpacity = 0
}
try? await Task.sleep(for: .milliseconds(420))
onComplete()
}
}

View File

@@ -125,7 +125,7 @@
"library.settings" = "Einstellungen";
/* Login */
"login.title" = "ABS Client";
"login.title" = "Nook";
"login.server_placeholder" = "https://mein-server.de";
"login.username" = "Benutzername";
"login.password" = "Passwort";
@@ -141,7 +141,7 @@
"sidebar.offline" = "Offline";
"sidebar.history" = "Verlauf";
"sidebar.logout" = "Abmelden";
"sidebar.app_title" = "ABS Client";
"sidebar.app_title" = "Nook";
"sidebar.status_online" = "Online";
"sidebar.status_offline" = "Offline";

View File

@@ -125,7 +125,7 @@
"library.settings" = "Settings";
/* Login */
"login.title" = "ABS Client";
"login.title" = "Nook";
"login.server_placeholder" = "https://my-server.com";
"login.username" = "Username";
"login.password" = "Password";
@@ -141,7 +141,7 @@
"sidebar.offline" = "Offline";
"sidebar.history" = "History";
"sidebar.logout" = "Sign out";
"sidebar.app_title" = "ABS Client";
"sidebar.app_title" = "Nook";
"sidebar.status_online" = "Online";
"sidebar.status_offline" = "Offline";

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>ABS Client</string>
<string>Nook</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -13,7 +13,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ABS Client</string>
<string>Nook</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>

View File

@@ -1,4 +1,4 @@
# ABS-Client
# Nook
> Ein nativer iOS- und macOS-Client für deine selbst gehostete [Audiobookshelf](https://www.audiobookshelf.org/)-Instanz.
@@ -6,37 +6,70 @@
## 📖 Über die App
**ABS-Client** ist eine native App für iPhone, iPad und Mac, die eine direkte Verbindung zu deiner eigenen Audiobookshelf-Serverinstanz herstellt. Du behältst die volle Kontrolle über deine Hörbuch-Bibliothek ganz ohne Drittanbieter oder Cloud-Abhängigkeit.
**Nook** ist eine native App für iPhone und Mac, die eine direkte Verbindung zu deiner eigenen Audiobookshelf-Serverinstanz herstellt. Du behältst die volle Kontrolle über deine Hörbuch-Bibliothek ganz ohne Drittanbieter oder Cloud-Abhängigkeit.
> ⚠️ **Hinweis:** Die App befindet sich noch in aktiver Entwicklung und ist aktuell noch nicht im App Store verfügbar. Der Release ist in Kürze geplant.
---
## 📲 Im App Store erhältlich
Nook ist sowohl für **iPhone** als auch für **Mac** kostenlos im App Store verfügbar:
👉 **[Nook im App Store öffnen](https://apps.apple.com/ch/app/audiobook-abs-client/id6770326901)**
---
## ✨ Features
### Aktuell verfügbar
### Bibliothek & Inhalte
- 🔗 **Serververbindung** Verbinde die App mit deiner eigenen Audiobookshelf-Instanz
- 📚 **Bibliotheks-Browser** Durchsuche und verwalte deine Hörbücher
- **Download** Lade Hörbücher für die Offline-Nutzung herunter
- 🎧 **Wiedergabe** Höre deine Hörbücher direkt in der App
- 📚 **Bibliotheks-Browser** Durchsuche deine Hörbücher in Kachel- oder Listenansicht
- 🎙 **Podcast-Unterstützung** Episoden direkt aus deiner ABS-Bibliothek wiedergeben
- 🖼️ **Cover-Cache** Cover werden lokal zwischengespeichert, kein erneutes Laden bei jedem App-Start
### Geplant
- ⏱️ **Einschlaftimer** Automatisches Stoppen der Wiedergabe nach einer festgelegten Zeit
- 🌍 **Mehrsprachigkeit** Die App ist aktuell nur auf Deutsch verfügbar; weitere Sprachen sind in Planung
### Wiedergabe
- 🎧 **Vollwertiger Player** Wiedergabe mit Scrubber, Skip-Buttons (15s / 30s / 45s / 60s / 90s konfigurierbar) und Geschwindigkeitsregler (0.75× bis 2.0×)
- 📑 **Kapitel-Navigation** Springe direkt zu Kapiteln; aktuelles Kapitel wird hervorgehoben
- 🔖 **Lesezeichen** Markiere beliebige Stellen mit eigenem Namen, synchronisiert mit deinem ABS-Server
- ⏱️ **Einschlaftimer** Mehrere Modi: Festgelegte Minuten, „Bis Ende des Kapitels" oder „Bis Ende des Hörbuchs"
- 🎚️ **Lockscreen- & Control-Center-Steuerung** Vollständige Wiedergabesteuerung von außerhalb der App
### Downloads & Offline
- ⬇️ **Offline-Downloads** Lade Hörbücher und Podcast-Episoden für die Offline-Wiedergabe
- 📊 **Live-Fortschritt** Echtzeit-Anzeige der heruntergeladenen MB pro Hörbuch
- 🔄 **Hintergrund-Downloads** *(iOS)* Downloads laufen weiter, auch wenn die App geschlossen oder das Gerät gesperrt ist
- 🎛️ **Parallel oder sequenziell** Wähle in den Einstellungen ob mehrere Downloads gleichzeitig oder nacheinander laufen sollen
- 📶 **Mobilnetz-Schutz** Optional Downloads nur über WLAN
- 🔔 **Live Activities** *(iOS)* Download-Fortschritt direkt im Lockscreen und Dynamic Island
### Hörfortschritt & Verlauf
- 🔄 **Bidirektionale Synchronisation** Hörfortschritt wird zum Server gepusht und periodisch nachgefragt; bei Konflikten (z. B. anderes Gerät war voraus) wird beim Fortsetzen gefragt
- 📜 **Hörverlauf** *(opt-in)* Lokales Protokoll deiner Hörpositionen, ideal um nach versehentlichem Verspulen schnell zurückzuspringen
- 💾 **XML-Export** Hörverlauf als XML exportieren und teilen
- 🌐 **Cross-Device Foreground-Sync** Beim Zurückkehren in die App wird der Server sofort um aktuelle Daten gefragt
### Sprache & Plattform
- 🇩🇪 **Deutsch** und 🇬🇧 **Englisch** Vollständig lokalisierte Oberfläche
- 🖥️ **macOS-Optimierung** Eigene Sidebar-Navigation, Tastatursteuerung und Multi-Window-Support
- 📱 **iOS-Optimierung** Liquid-Glass-Design, native Sheets und kontextuelle Menüs
---
## 📋 Voraussetzungen
- **iOS / iPadOS:** 26.0 oder neuer
- **iOS:** 26.0 oder neuer (iPhone)
- **macOS:** 26.0 (Tahoe) oder neuer
- Eine laufende [Audiobookshelf](https://github.com/advplyr/audiobookshelf)-Serverinstanz
- Eine laufende [Audiobookshelf](https://github.com/advplyr/audiobookshelf)-Serverinstanz (Version 2.x)
---
## 🚀 Installation
Die App ist noch nicht im App Store verfügbar. Wer die App bereits jetzt nutzen oder testen möchte, kann das Projekt selbst aus dem Quellcode bauen:
### Aus dem App Store
👉 **[Nook im App Store öffnen](https://apps.apple.com/ch/app/audiobook-abs-client/id6770326901)**
Der Link funktioniert sowohl für iPhone als auch für Mac — der App Store leitet automatisch zur passenden Version weiter.
### Aus dem Quellcode bauen
```bash
git clone https://git.scarriffle.com/Scarriffle/ABS-Client.git
@@ -72,6 +105,7 @@ Dieses Projekt ist lizenziert unter der **GNU General Public License v3.0**.
Das bedeutet: Du kannst den Code frei nutzen, verändern und weitergeben aber abgeleitete Projekte müssen ebenfalls unter der GPL v3 veröffentlicht werden.
Den vollständigen Lizenztext findest du in der [LICENSE](LICENSE)-Datei.
---
## 📬 Kontakt
@@ -82,4 +116,4 @@ Bei Fragen, Feedback oder Problemen:
---
> ABS-Client ist ein unabhängiges Community-Projekt und steht in keiner Verbindung zum offiziellen [Audiobookshelf-Projekt](https://github.com/advplyr/audiobookshelf).
> Nook ist ein unabhängiges Community-Projekt und steht in keiner Verbindung zum offiziellen [Audiobookshelf-Projekt](https://github.com/advplyr/audiobookshelf).