Compare commits
3 Commits
7ca511d37f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9497c6e315 | ||
|
|
fa47cae664 | ||
|
|
15d8e71d09 |
12
ABS Client/ABS Client-macOS.entitlements
Normal file
12
ABS Client/ABS Client-macOS.entitlements
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.files.user-selected.read-write</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -90,6 +90,7 @@
|
|||||||
hasScannedForEncodings = 0;
|
hasScannedForEncodings = 0;
|
||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
|
de,
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = 39614D022FB4D44400DBEF5E;
|
mainGroup = 39614D022FB4D44400DBEF5E;
|
||||||
@@ -254,22 +255,24 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "ABS Client-macOS.entitlements";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
"ENABLE_APP_SANDBOX[sdk=macosx*]" = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "ABS Client";
|
|
||||||
INFOPLIST_KEY_CFBundleName = "ABS Client";
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
|
||||||
"GENERATE_INFOPLIST_FILE[sdk=iphoneos*]" = NO;
|
"GENERATE_INFOPLIST_FILE[sdk=iphoneos*]" = NO;
|
||||||
"GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
|
"GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
|
||||||
"INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
|
"INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
|
||||||
"INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
|
"INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "ABS Client";
|
||||||
|
INFOPLIST_KEY_CFBundleName = "ABS Client";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -283,7 +286,8 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
MARKETING_VERSION = 2.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
||||||
PRODUCT_NAME = "ABS Client";
|
PRODUCT_NAME = "ABS Client";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -296,7 +300,7 @@
|
|||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -305,22 +309,24 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
"CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "ABS Client-macOS.entitlements";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
"ENABLE_APP_SANDBOX[sdk=macosx*]" = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "ABS Client";
|
|
||||||
INFOPLIST_KEY_CFBundleName = "ABS Client";
|
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
|
||||||
"GENERATE_INFOPLIST_FILE[sdk=iphoneos*]" = NO;
|
"GENERATE_INFOPLIST_FILE[sdk=iphoneos*]" = NO;
|
||||||
"GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
|
"GENERATE_INFOPLIST_FILE[sdk=iphonesimulator*]" = NO;
|
||||||
"INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
|
"INFOPLIST_FILE[sdk=iphoneos*]" = "Info-iOS.plist";
|
||||||
"INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
|
"INFOPLIST_FILE[sdk=iphonesimulator*]" = "Info-iOS.plist";
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "ABS Client";
|
||||||
|
INFOPLIST_KEY_CFBundleName = "ABS Client";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.books";
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -334,7 +340,8 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
|
MARKETING_VERSION = 2.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.ABS-Client";
|
||||||
PRODUCT_NAME = "ABS Client";
|
PRODUCT_NAME = "ABS Client";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -347,7 +354,7 @@
|
|||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = 1;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 895 KiB After Width: | Height: | Size: 726 KiB |
@@ -8,11 +8,24 @@ struct Audiobookshelf_swiftApp: App {
|
|||||||
@State private var appState = AppState()
|
@State private var appState = AppState()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
configureImageCache()
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
configureAudioSession()
|
configureAudioSession()
|
||||||
#endif
|
#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 {
|
var body: some Scene {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
@@ -35,13 +48,9 @@ struct Audiobookshelf_swiftApp: App {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private func configureAudioSession() {
|
private func configureAudioSession() {
|
||||||
do {
|
// Nur die Kategorie registrieren — setActive(true) passiert erst in play(),
|
||||||
let session = AVAudioSession.sharedInstance()
|
// damit beim App-Start keine laufende Fremd-Wiedergabe unterbrochen wird.
|
||||||
try session.setCategory(.playback, mode: .default, options: [])
|
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
|
||||||
try session.setActive(true)
|
|
||||||
} catch {
|
|
||||||
// non-fatal
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,11 +31,19 @@ struct LibraryItemDTO: Decodable {
|
|||||||
let media: MediaDTO?
|
let media: MediaDTO?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ChapterDTO: Decodable {
|
||||||
|
let id: Int?
|
||||||
|
let start: Double?
|
||||||
|
let end: Double?
|
||||||
|
let title: String?
|
||||||
|
}
|
||||||
|
|
||||||
struct MediaDTO: Decodable {
|
struct MediaDTO: Decodable {
|
||||||
let metadata: MetadataDTO?
|
let metadata: MetadataDTO?
|
||||||
let duration: Double?
|
let duration: Double?
|
||||||
let audioFiles: [AudioFileDTO]?
|
let audioFiles: [AudioFileDTO]?
|
||||||
let episodes: [EpisodeDTO]?
|
let episodes: [EpisodeDTO]?
|
||||||
|
let chapters: [ChapterDTO]?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MetadataDTO: Decodable {
|
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 {
|
struct ProgressResponseDTO: Decodable {
|
||||||
let id: String?
|
let id: String?
|
||||||
let libraryItemId: String?
|
let libraryItemId: String?
|
||||||
@@ -77,6 +91,7 @@ struct ProgressResponseDTO: Decodable {
|
|||||||
let duration: Double?
|
let duration: Double?
|
||||||
let isFinished: Bool?
|
let isFinished: Bool?
|
||||||
let lastUpdate: Double?
|
let lastUpdate: Double?
|
||||||
|
let bookmarks: [BookmarkDTO]?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MeResponseDTO: Decodable {
|
struct MeResponseDTO: Decodable {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
struct Chapter: Codable, Identifiable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let start: Double
|
||||||
|
let end: Double
|
||||||
|
let title: String
|
||||||
|
}
|
||||||
|
|
||||||
struct Library: Codable, Identifiable, Hashable {
|
struct Library: Codable, Identifiable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
@@ -15,6 +22,7 @@ struct LibraryItem: Codable, Identifiable, Hashable {
|
|||||||
var mediaType: String = "book"
|
var mediaType: String = "book"
|
||||||
var episodeId: String? = nil
|
var episodeId: String? = nil
|
||||||
var description: String? = nil
|
var description: String? = nil
|
||||||
|
var chapters: [Chapter] = []
|
||||||
|
|
||||||
var isPodcast: Bool { mediaType == "podcast" }
|
var isPodcast: Bool { mediaType == "podcast" }
|
||||||
var isPodcastContainer: Bool { isPodcast && episodeId == nil }
|
var isPodcastContainer: Bool { isPodcast && episodeId == nil }
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ final class ABSClient {
|
|||||||
)
|
)
|
||||||
item.mediaType = mediaType
|
item.mediaType = mediaType
|
||||||
item.description = meta?.description
|
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
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,10 +167,15 @@ final class ABSClient {
|
|||||||
currentTime: dto.currentTime ?? 0,
|
currentTime: dto.currentTime ?? 0,
|
||||||
duration: dto.duration ?? 0,
|
duration: dto.duration ?? 0,
|
||||||
isFinished: dto.isFinished ?? false,
|
isFinished: dto.isFinished ?? false,
|
||||||
updatedAt: Date()
|
updatedAt: Self.parseLastUpdate(dto.lastUpdate)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func parseLastUpdate(_ ms: Double?) -> Date {
|
||||||
|
guard let ms, ms > 0 else { return Date() }
|
||||||
|
return Date(timeIntervalSince1970: ms / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
func saveProgress(_ progress: PlaybackProgress) async throws {
|
func saveProgress(_ progress: PlaybackProgress) async throws {
|
||||||
let body: [String: Any] = [
|
let body: [String: Any] = [
|
||||||
"currentTime": progress.currentTime,
|
"currentTime": progress.currentTime,
|
||||||
@@ -196,7 +205,7 @@ final class ABSClient {
|
|||||||
currentTime: p.currentTime ?? 0,
|
currentTime: p.currentTime ?? 0,
|
||||||
duration: p.duration ?? 0,
|
duration: p.duration ?? 0,
|
||||||
isFinished: p.isFinished ?? false,
|
isFinished: p.isFinished ?? false,
|
||||||
updatedAt: Date()
|
updatedAt: Self.parseLastUpdate(p.lastUpdate)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,4 +234,43 @@ final class ABSClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var bearerHeader: [String: String] { ["Authorization": "Bearer \(auth.token)"] }
|
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?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,28 @@ final class AppState {
|
|||||||
let downloads: DownloadManager
|
let downloads: DownloadManager
|
||||||
let sync: ProgressSyncManager
|
let sync: ProgressSyncManager
|
||||||
let player: PlayerEngine
|
let player: PlayerEngine
|
||||||
|
let history: HistoryManager
|
||||||
|
let bookmarks: BookmarkManager
|
||||||
|
|
||||||
var currentItem: LibraryItem?
|
var currentItem: LibraryItem?
|
||||||
var isPreparingPlayback: Bool = false
|
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).
|
/// Map: PlaybackProgress.syncKey -> PlaybackProgress (server-known progress).
|
||||||
/// Used to show progress bars on covers in the library views.
|
/// Used to show progress bars on covers in the library views.
|
||||||
var progressCache: [String: PlaybackProgress] = [:]
|
var progressCache: [String: PlaybackProgress] = [:]
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
var pendingServerProgress: PlaybackProgress?
|
||||||
|
|
||||||
private var syncTimer: Timer?
|
private var syncTimer: Timer?
|
||||||
|
private var pullTimer: Timer?
|
||||||
private var lastReportedSecond: Double = -10
|
private var lastReportedSecond: Double = -10
|
||||||
|
private var lastPushedAt: Date = .distantPast
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let auth = AuthStore()
|
let auth = AuthStore()
|
||||||
@@ -30,6 +42,27 @@ final class AppState {
|
|||||||
self.downloads = DownloadManager(client: client)
|
self.downloads = DownloadManager(client: client)
|
||||||
self.sync = ProgressSyncManager(client: client)
|
self.sync = ProgressSyncManager(client: client)
|
||||||
self.player = PlayerEngine()
|
self.player = PlayerEngine()
|
||||||
|
self.history = HistoryManager()
|
||||||
|
self.bookmarks = BookmarkManager()
|
||||||
|
// Route lockscreen/Control-Center seeks through AppState so history is
|
||||||
|
// recorded — otherwise remote skips bypass history entirely.
|
||||||
|
self.player.onRemoteSkip = { [weak self] seconds in
|
||||||
|
self?.skip(by: seconds)
|
||||||
|
}
|
||||||
|
self.player.onRemoteSeek = { [weak self] target in
|
||||||
|
self?.seekAbsolute(target)
|
||||||
|
}
|
||||||
|
// PlayerEngine reports chapter transitions from the AVPlayer time
|
||||||
|
// observer — fires reliably in background/locked, unlike the 5s Timer.
|
||||||
|
self.player.onChapterChanged = { [weak self] chapter in
|
||||||
|
self?.recordChapterEntry(chapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordChapterEntry(_ chapter: Chapter) {
|
||||||
|
guard let item = currentItem,
|
||||||
|
UserDefaults.standard.bool(forKey: "historyEnabled") else { return }
|
||||||
|
history.record(item: item, position: chapter.start, chapters: item.chapters)
|
||||||
}
|
}
|
||||||
|
|
||||||
func bootstrap() async {
|
func bootstrap() async {
|
||||||
@@ -52,6 +85,59 @@ final class AppState {
|
|||||||
await refreshProgressCache()
|
await refreshProgressCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
startPullTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by ContentView on scenePhase == .active. Immediate pull so we
|
||||||
|
/// notice updates from other devices the moment the app comes forward.
|
||||||
|
func onScenePhaseActive() {
|
||||||
|
Task { await pullAndReconcile() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPullTimer() {
|
||||||
|
pullTimer?.invalidate()
|
||||||
|
let timer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { _ in
|
||||||
|
Task { @MainActor [weak self] in await self?.pullAndReconcile() }
|
||||||
|
}
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
pullTimer = timer
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pulls server progress and reconciles against local state:
|
||||||
|
/// - currentItem == nil: just refresh the cache (library covers).
|
||||||
|
/// - server is newer than local: stash for the resume-prompt.
|
||||||
|
/// - server is older than local: push our state immediately.
|
||||||
|
func pullAndReconcile() async {
|
||||||
|
guard network.isOnline, auth.isLoggedIn else { return }
|
||||||
|
await refreshProgressCache()
|
||||||
|
|
||||||
|
guard let current = currentItem else { return }
|
||||||
|
guard let server = progressCache[current.syncKey] else { return }
|
||||||
|
|
||||||
|
let local = player.absoluteCurrentTime
|
||||||
|
let positionDelta = abs(server.currentTime - local)
|
||||||
|
// Treat <8 s delta as identical to absorb own-update echoes, clock skew,
|
||||||
|
// and reporting granularity.
|
||||||
|
guard positionDelta > 8 else { return }
|
||||||
|
|
||||||
|
let serverIsNewer = server.updatedAt > lastPushedAt.addingTimeInterval(5)
|
||||||
|
if serverIsNewer {
|
||||||
|
pendingServerProgress = server
|
||||||
|
} else {
|
||||||
|
reportProgress(force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func acceptPendingServerProgress() {
|
||||||
|
guard let p = pendingServerProgress else { return }
|
||||||
|
pendingServerProgress = nil
|
||||||
|
player.seekAbsolute(p.currentTime)
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissPendingServerProgress() {
|
||||||
|
pendingServerProgress = nil
|
||||||
|
player.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pulls the entire progress map from the server (via /api/me).
|
/// Pulls the entire progress map from the server (via /api/me).
|
||||||
@@ -86,27 +172,34 @@ final class AppState {
|
|||||||
return min(1, max(0, p.currentTime / p.duration))
|
return min(1, max(0, p.currentTime / p.duration))
|
||||||
}
|
}
|
||||||
|
|
||||||
func play(item: LibraryItem) async {
|
func play(item: LibraryItem, overrideStartAt: Double? = nil) async {
|
||||||
|
// Clear any stash from a previous item — only carry stashes per-item.
|
||||||
|
pendingServerProgress = nil
|
||||||
if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady {
|
if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady {
|
||||||
player.play()
|
if let pos = overrideStartAt { seekAbsolute(pos) } else { player.play() }
|
||||||
return
|
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)
|
stopPlayback(reportFinal: true)
|
||||||
isPreparingPlayback = true
|
isPreparingPlayback = true
|
||||||
defer { isPreparingPlayback = false }
|
defer { isPreparingPlayback = false }
|
||||||
|
|
||||||
var workItem = item
|
var workItem = item
|
||||||
// Only fetch detail for books with empty audioFiles (podcast episodes
|
// Always fetch detail when online so chapters are loaded — also for
|
||||||
// arrive with their single audioFile already populated by the caller).
|
// already-downloaded items (the persisted DownloadedItem doesn't store
|
||||||
if !workItem.isPodcast && workItem.audioFiles.isEmpty && network.isOnline {
|
// chapter metadata, so streaming the detail is the only source).
|
||||||
let alreadyDownloaded = downloads.isDownloaded(downloadKey: item.downloadKey)
|
if !workItem.isPodcast && network.isOnline {
|
||||||
if !alreadyDownloaded, let detail = try? await client.fetchItemDetail(itemId: item.id) {
|
if let detail = try? await client.fetchItemDetail(itemId: item.id) {
|
||||||
workItem = detail
|
workItem = detail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var startAt: Double = 0
|
var startAt: Double = overrideStartAt ?? 0
|
||||||
if network.isOnline {
|
if overrideStartAt == nil && network.isOnline {
|
||||||
if let p = try? await client.fetchProgress(itemId: item.id, episodeId: workItem.episodeId) {
|
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)
|
// 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.
|
// should start from the beginning, not drop the user at the last few seconds.
|
||||||
@@ -116,14 +209,37 @@ 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
|
currentItem = workItem
|
||||||
player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
|
player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
|
||||||
if player.errorMessage == nil {
|
if player.errorMessage == nil {
|
||||||
player.play()
|
player.play()
|
||||||
startSyncTimer()
|
startSyncTimer()
|
||||||
|
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.
|
/// Convenience for podcast episodes.
|
||||||
func play(podcast: LibraryItem, episode: PodcastEpisode) async {
|
func play(podcast: LibraryItem, episode: PodcastEpisode) async {
|
||||||
@@ -145,6 +261,7 @@ final class AppState {
|
|||||||
syncTimer = nil
|
syncTimer = nil
|
||||||
player.teardown()
|
player.teardown()
|
||||||
currentItem = nil
|
currentItem = nil
|
||||||
|
pendingServerProgress = nil
|
||||||
lastReportedSecond = -10
|
lastReportedSecond = -10
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,13 +272,19 @@ final class AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func skip(by seconds: Double) {
|
func skip(by seconds: 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.skip(by: seconds)
|
player.skip(by: seconds)
|
||||||
reportProgress(force: true)
|
reportProgress(force: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func seekAbsolute(_ target: Double) {
|
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)
|
player.seekAbsolute(target)
|
||||||
reportProgress(force: true)
|
reportProgress(force: true)
|
||||||
}
|
}
|
||||||
@@ -170,6 +293,22 @@ final class AppState {
|
|||||||
player.setRate(newRate)
|
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() {
|
private func startSyncTimer() {
|
||||||
syncTimer?.invalidate()
|
syncTimer?.invalidate()
|
||||||
let timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
|
let timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
|
||||||
@@ -197,6 +336,7 @@ final class AppState {
|
|||||||
duration: d,
|
duration: d,
|
||||||
isFinished: finished
|
isFinished: finished
|
||||||
)
|
)
|
||||||
|
lastPushedAt = Date()
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await sync.report(
|
await sync.report(
|
||||||
|
|||||||
@@ -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)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,11 +66,21 @@ struct DownloadedItem: Codable, Hashable {
|
|||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class DownloadManager {
|
final class DownloadManager: @unchecked Sendable {
|
||||||
private let client: ABSClient
|
private let client: ABSClient
|
||||||
/// Keyed by downloadKey (itemId or "itemId|episodeId").
|
/// Keyed by downloadKey (itemId or "itemId|episodeId").
|
||||||
private(set) var states: [String: DownloadState] = [:]
|
private(set) var states: [String: DownloadState] = [:]
|
||||||
private(set) var downloadedItems: [String: DownloadedItem] = [:]
|
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] = [:]
|
||||||
|
/// Current track index (0-based) per active download. Set at the start of
|
||||||
|
/// each track iteration, cleared via defer when the download exits.
|
||||||
|
private var currentTrackIndex: [String: Int] = [:]
|
||||||
|
/// Total number of tracks per active download.
|
||||||
|
private var totalTrackCount: [String: Int] = [:]
|
||||||
|
|
||||||
private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") }
|
private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") }
|
||||||
private var activeTasks: [String: Task<Void, Never>] = [:]
|
private var activeTasks: [String: Task<Void, Never>] = [:]
|
||||||
@@ -90,6 +100,51 @@ final class DownloadManager {
|
|||||||
return false
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called from the URL session delegate with the fraction (0…1) of the
|
||||||
|
/// currently downloading track. Combines with the completed-track count to
|
||||||
|
/// drive a smooth overall progress ring, even for single-track downloads.
|
||||||
|
nonisolated func _reportTrackByteFraction(_ fraction: Double, for downloadKey: String) {
|
||||||
|
Task { @MainActor [self] in
|
||||||
|
guard let idx = self.currentTrackIndex[downloadKey],
|
||||||
|
let total = self.totalTrackCount[downloadKey],
|
||||||
|
total > 0 else { return }
|
||||||
|
let overall = (Double(idx) + max(0, min(1, fraction))) / Double(total)
|
||||||
|
// Clamp below 1.0 so `performDownload` is the only place that
|
||||||
|
// transitions to `.downloaded` after the final track is persisted.
|
||||||
|
self.states[downloadKey] = .downloading(progress: min(0.999, overall))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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]? {
|
func localTrackURLs(for downloadKey: String) -> [URL]? {
|
||||||
guard let item = downloadedItems[downloadKey] else { return nil }
|
guard let item = downloadedItems[downloadKey] else { return nil }
|
||||||
return item.tracks.map { AppPaths.downloadsDirectory.appendingPathComponent($0.localPath) }
|
return item.tracks.map { AppPaths.downloadsDirectory.appendingPathComponent($0.localPath) }
|
||||||
@@ -100,6 +155,7 @@ final class DownloadManager {
|
|||||||
let key = item.downloadKey
|
let key = item.downloadKey
|
||||||
guard activeTasks[key] == nil else { return }
|
guard activeTasks[key] == nil else { return }
|
||||||
states[key] = .downloading(progress: 0)
|
states[key] = .downloading(progress: 0)
|
||||||
|
pendingItems[key] = item
|
||||||
|
|
||||||
let task = Task { @MainActor [weak self] in
|
let task = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -110,12 +166,14 @@ final class DownloadManager {
|
|||||||
workItem = try await self.client.fetchItemDetail(itemId: item.id)
|
workItem = try await self.client.fetchItemDetail(itemId: item.id)
|
||||||
} catch {
|
} catch {
|
||||||
self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)")
|
self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)")
|
||||||
|
self.pendingItems.removeValue(forKey: key)
|
||||||
self.activeTasks[key] = nil
|
self.activeTasks[key] = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if workItem.audioFiles.isEmpty {
|
if workItem.audioFiles.isEmpty {
|
||||||
self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.")
|
self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.")
|
||||||
|
self.pendingItems.removeValue(forKey: key)
|
||||||
self.activeTasks[key] = nil
|
self.activeTasks[key] = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -129,9 +187,12 @@ final class DownloadManager {
|
|||||||
activeTasks[downloadKey]?.cancel()
|
activeTasks[downloadKey]?.cancel()
|
||||||
activeTasks[downloadKey] = nil
|
activeTasks[downloadKey] = nil
|
||||||
states[downloadKey] = .notDownloaded
|
states[downloadKey] = .notDownloaded
|
||||||
|
pendingItems.removeValue(forKey: downloadKey)
|
||||||
|
inFlightBytes.removeValue(forKey: downloadKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(downloadKey: String) {
|
func delete(downloadKey: String) {
|
||||||
|
pendingItems.removeValue(forKey: downloadKey)
|
||||||
cancel(downloadKey: downloadKey)
|
cancel(downloadKey: downloadKey)
|
||||||
if let item = downloadedItems[downloadKey] {
|
if let item = downloadedItems[downloadKey] {
|
||||||
let dir = directoryURL(itemId: item.itemId, episodeId: item.episodeId)
|
let dir = directoryURL(itemId: item.itemId, episodeId: item.episodeId)
|
||||||
@@ -148,6 +209,18 @@ final class DownloadManager {
|
|||||||
persistIndex()
|
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 {
|
private func directoryURL(itemId: String, episodeId: String?) -> URL {
|
||||||
var dir = AppPaths.downloadsDirectory.appendingPathComponent(itemId, isDirectory: true)
|
var dir = AppPaths.downloadsDirectory.appendingPathComponent(itemId, isDirectory: true)
|
||||||
if let episodeId {
|
if let episodeId {
|
||||||
@@ -162,6 +235,12 @@ final class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func performDownload(workItem: LibraryItem, downloadKey: String) async {
|
private func performDownload(workItem: LibraryItem, downloadKey: String) async {
|
||||||
|
defer {
|
||||||
|
pendingItems.removeValue(forKey: downloadKey)
|
||||||
|
inFlightBytes.removeValue(forKey: downloadKey)
|
||||||
|
currentTrackIndex.removeValue(forKey: downloadKey)
|
||||||
|
totalTrackCount.removeValue(forKey: downloadKey)
|
||||||
|
}
|
||||||
let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId)
|
let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId)
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)
|
||||||
@@ -172,6 +251,7 @@ final class DownloadManager {
|
|||||||
|
|
||||||
var tracks: [DownloadedTrack] = []
|
var tracks: [DownloadedTrack] = []
|
||||||
let total = max(workItem.audioFiles.count, 1)
|
let total = max(workItem.audioFiles.count, 1)
|
||||||
|
totalTrackCount[downloadKey] = total
|
||||||
|
|
||||||
for (idx, file) in workItem.audioFiles.enumerated() {
|
for (idx, file) in workItem.audioFiles.enumerated() {
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
@@ -181,10 +261,11 @@ final class DownloadManager {
|
|||||||
guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue }
|
guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue }
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) }
|
for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) }
|
||||||
|
currentTrackIndex[downloadKey] = idx
|
||||||
|
|
||||||
let tempURL: URL
|
let tempURL: URL
|
||||||
do {
|
do {
|
||||||
tempURL = try await downloadWithRetry(request: request, filename: file.filename)
|
tempURL = try await downloadWithRetry(request: request, filename: file.filename, downloadKey: downloadKey)
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
states[downloadKey] = .notDownloaded
|
states[downloadKey] = .notDownloaded
|
||||||
return
|
return
|
||||||
@@ -210,6 +291,9 @@ final class DownloadManager {
|
|||||||
durationSeconds: file.durationSeconds
|
durationSeconds: file.durationSeconds
|
||||||
))
|
))
|
||||||
states[downloadKey] = .downloading(progress: Double(idx + 1) / Double(total))
|
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(
|
let downloaded = DownloadedItem(
|
||||||
@@ -226,22 +310,22 @@ final class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Downloads with up to `maxAttempts` retries and resume-data support so a brief
|
/// 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
|
/// network dropout picks up where it left off. Uses a classic URLSessionDownloadTask
|
||||||
/// self-signed server certificates are accepted.
|
/// with explicit delegate (wrapped in a continuation) so we reliably get
|
||||||
private func downloadWithRetry(request: URLRequest, filename: String, maxAttempts: Int = 5) async throws -> URL {
|
/// `didWriteData` progress callbacks — the async `download(for:delegate:)` API
|
||||||
let session = client.session
|
/// 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 resumeData: Data? = nil
|
||||||
var lastError: Error = URLError(.unknown)
|
var lastError: Error = URLError(.unknown)
|
||||||
|
|
||||||
for attempt in 0..<maxAttempts {
|
for attempt in 0..<maxAttempts {
|
||||||
try Task.checkCancellation()
|
try Task.checkCancellation()
|
||||||
do {
|
do {
|
||||||
let (tempURL, response): (URL, URLResponse)
|
let (tempURL, response) = try await streamingDownload(
|
||||||
if let resume = resumeData {
|
request: request,
|
||||||
(tempURL, response) = try await session.download(resumeFrom: resume)
|
resumeData: resumeData,
|
||||||
} else {
|
downloadKey: downloadKey
|
||||||
(tempURL, response) = try await session.download(for: request)
|
)
|
||||||
}
|
|
||||||
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
||||||
try? FileManager.default.removeItem(at: tempURL)
|
try? FileManager.default.removeItem(at: tempURL)
|
||||||
throw URLError(.badServerResponse)
|
throw URLError(.badServerResponse)
|
||||||
@@ -262,6 +346,32 @@ final class DownloadManager {
|
|||||||
throw lastError
|
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() {
|
private func loadIndex() {
|
||||||
guard let data = try? Data(contentsOf: indexFile),
|
guard let data = try? Data(contentsOf: indexFile),
|
||||||
let decoded = try? JSONDecoder().decode([String: DownloadedItem].self, from: data) else { return }
|
let decoded = try? JSONDecoder().decode([String: DownloadedItem].self, from: data) else { return }
|
||||||
@@ -292,3 +402,82 @@ 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)
|
||||||
|
if totalBytesExpectedToWrite > 0 {
|
||||||
|
let fraction = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
||||||
|
manager._reportTrackByteFraction(fraction, 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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: "&")
|
||||||
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
try? FileManager.default.createDirectory(
|
||||||
|
at: AppPaths.supportDirectory, withIntermediateDirectories: true)
|
||||||
|
if let data = try? JSONEncoder().encode(entries) {
|
||||||
|
try? data.write(to: saveFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() {
|
||||||
|
guard let data = try? Data(contentsOf: saveFile) else { return }
|
||||||
|
entries = (try? JSONDecoder().decode([HistoryEntry].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,13 @@ import AppKit
|
|||||||
private typealias PlayerArtworkImage = NSImage
|
private typealias PlayerArtworkImage = NSImage
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
enum SleepTimerMode: Equatable, Hashable {
|
||||||
|
case off
|
||||||
|
case minutes(Int)
|
||||||
|
case endOfBook
|
||||||
|
case endOfChapter
|
||||||
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PlayerEngine {
|
final class PlayerEngine {
|
||||||
@@ -21,6 +28,27 @@ final class PlayerEngine {
|
|||||||
var isReady: Bool = false
|
var isReady: Bool = false
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
|
var sleepTimer: SleepTimerMode = .off
|
||||||
|
/// Verbleibende Wallclock-Sekunden bis der Sleep-Timer auslöst (0 wenn off).
|
||||||
|
/// Pausiert mit der Wiedergabe; bei `.endOfBook`/`.endOfChapter` rate-skaliert aus der Restspielzeit.
|
||||||
|
var sleepRemainingSeconds: Double = 0
|
||||||
|
var chapters: [Chapter] = []
|
||||||
|
/// Set by AppState. Fired when a remote/system control (lockscreen, Control
|
||||||
|
/// Center, headphones) triggers a skip, so AppState can record history and
|
||||||
|
/// run its own seek path instead of bypassing it.
|
||||||
|
var onRemoteSkip: ((Double) -> Void)?
|
||||||
|
/// Set by AppState. Fired when a remote control changes playback position.
|
||||||
|
var onRemoteSeek: ((Double) -> Void)?
|
||||||
|
/// Set by AppState. Fired by the AVPlayer periodic time observer whenever
|
||||||
|
/// playback crosses into a new chapter — works reliably in background and
|
||||||
|
/// with the device locked, unlike a runloop-scheduled Timer.
|
||||||
|
var onChapterChanged: ((Chapter) -> Void)?
|
||||||
|
private var lastObservedChapterId: Int?
|
||||||
|
|
||||||
|
var currentChapter: Chapter? {
|
||||||
|
chapters.last { $0.start <= absoluteCurrentTime }
|
||||||
|
}
|
||||||
|
|
||||||
private var player: AVQueuePlayer?
|
private var player: AVQueuePlayer?
|
||||||
private var trackDurations: [Double] = []
|
private var trackDurations: [Double] = []
|
||||||
private var trackPlayerItems: [AVPlayerItem] = []
|
private var trackPlayerItems: [AVPlayerItem] = []
|
||||||
@@ -28,6 +56,7 @@ final class PlayerEngine {
|
|||||||
private var timeObserver: Any?
|
private var timeObserver: Any?
|
||||||
private var endObservers: [NSObjectProtocol] = []
|
private var endObservers: [NSObjectProtocol] = []
|
||||||
private var isSeeking: Bool = false
|
private var isSeeking: Bool = false
|
||||||
|
private var sleepTickTask: Task<Void, Never>?
|
||||||
|
|
||||||
var itemId: String?
|
var itemId: String?
|
||||||
|
|
||||||
@@ -106,6 +135,10 @@ final class PlayerEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.chapters = item.chapters
|
||||||
|
// Reset chapter tracking — first observation after load is "initial",
|
||||||
|
// not a transition.
|
||||||
|
lastObservedChapterId = nil
|
||||||
currentTitle = item.title
|
currentTitle = item.title
|
||||||
currentAuthor = item.author
|
currentAuthor = item.author
|
||||||
currentCoverURL = client.coverURL(itemId: item.id)
|
currentCoverURL = client.coverURL(itemId: item.id)
|
||||||
@@ -127,12 +160,16 @@ final class PlayerEngine {
|
|||||||
player?.play()
|
player?.play()
|
||||||
player?.rate = rate
|
player?.rate = rate
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
|
if case .minutes = sleepTimer, sleepRemainingSeconds > 0, sleepTickTask == nil {
|
||||||
|
startSleepTickTask()
|
||||||
|
}
|
||||||
updateNowPlayingInfo()
|
updateNowPlayingInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
func pause() {
|
func pause() {
|
||||||
player?.pause()
|
player?.pause()
|
||||||
isPlaying = false
|
isPlaying = false
|
||||||
|
cancelSleepTickTask()
|
||||||
updateNowPlayingInfo()
|
updateNowPlayingInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +180,11 @@ final class PlayerEngine {
|
|||||||
func setRate(_ newRate: Float) {
|
func setRate(_ newRate: Float) {
|
||||||
rate = newRate
|
rate = newRate
|
||||||
if isPlaying { player?.rate = newRate }
|
if isPlaying { player?.rate = newRate }
|
||||||
|
switch sleepTimer {
|
||||||
|
case .endOfBook: sleepRemainingSeconds = wallclockRemainingUntilEndOfBook()
|
||||||
|
case .endOfChapter: sleepRemainingSeconds = wallclockRemainingUntilEndOfChapter()
|
||||||
|
default: break
|
||||||
|
}
|
||||||
updateNowPlayingInfo()
|
updateNowPlayingInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,9 +263,27 @@ final class PlayerEngine {
|
|||||||
let wasPlaying = isPlaying
|
let wasPlaying = isPlaying
|
||||||
isPlaying = player.timeControlStatus == .playing
|
isPlaying = player.timeControlStatus == .playing
|
||||||
if wasPlaying != isPlaying { updateNowPlayingInfo() }
|
if wasPlaying != isPlaying { updateNowPlayingInfo() }
|
||||||
|
updateEndOfBookSleep()
|
||||||
|
updateEndOfChapterSleep()
|
||||||
|
detectChapterTransition()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detectChapterTransition() {
|
||||||
|
let currentId = currentChapter?.id
|
||||||
|
guard currentId != lastObservedChapterId else { return }
|
||||||
|
let priorId = lastObservedChapterId
|
||||||
|
lastObservedChapterId = currentId
|
||||||
|
// Skip the very first observation after load (priorId == nil) — that's
|
||||||
|
// the initial position, not a real transition. AppState records that
|
||||||
|
// separately in `play(item:)`.
|
||||||
|
guard priorId != nil, let chapter = currentChapter else { return }
|
||||||
|
onChapterChanged?(chapter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func teardown() {
|
func teardown() {
|
||||||
|
cancelSleepTickTask()
|
||||||
|
sleepTimer = .off
|
||||||
|
sleepRemainingSeconds = 0
|
||||||
if let token = timeObserver { player?.removeTimeObserver(token) }
|
if let token = timeObserver { player?.removeTimeObserver(token) }
|
||||||
timeObserver = nil
|
timeObserver = nil
|
||||||
for obs in endObservers { NotificationCenter.default.removeObserver(obs) }
|
for obs in endObservers { NotificationCenter.default.removeObserver(obs) }
|
||||||
@@ -241,6 +301,8 @@ final class PlayerEngine {
|
|||||||
itemId = nil
|
itemId = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
isSeeking = false
|
isSeeking = false
|
||||||
|
chapters = []
|
||||||
|
lastObservedChapterId = nil
|
||||||
currentTitle = ""
|
currentTitle = ""
|
||||||
currentAuthor = ""
|
currentAuthor = ""
|
||||||
currentCoverURL = nil
|
currentCoverURL = nil
|
||||||
@@ -248,6 +310,75 @@ final class PlayerEngine {
|
|||||||
clearNowPlayingInfo()
|
clearNowPlayingInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sleep timer
|
||||||
|
|
||||||
|
func setSleepTimer(_ mode: SleepTimerMode) {
|
||||||
|
cancelSleepTickTask()
|
||||||
|
sleepTimer = mode
|
||||||
|
switch mode {
|
||||||
|
case .off:
|
||||||
|
sleepRemainingSeconds = 0
|
||||||
|
case .minutes(let m):
|
||||||
|
sleepRemainingSeconds = Double(m * 60)
|
||||||
|
if isPlaying { startSleepTickTask() }
|
||||||
|
case .endOfBook:
|
||||||
|
sleepRemainingSeconds = wallclockRemainingUntilEndOfBook()
|
||||||
|
case .endOfChapter:
|
||||||
|
sleepRemainingSeconds = wallclockRemainingUntilEndOfChapter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startSleepTickTask() {
|
||||||
|
cancelSleepTickTask()
|
||||||
|
sleepTickTask = Task { @MainActor [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
self.sleepRemainingSeconds = max(0, self.sleepRemainingSeconds - 0.5)
|
||||||
|
if self.sleepRemainingSeconds <= 0 {
|
||||||
|
self.sleepTimer = .off
|
||||||
|
self.pause()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelSleepTickTask() {
|
||||||
|
sleepTickTask?.cancel()
|
||||||
|
sleepTickTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateEndOfBookSleep() {
|
||||||
|
guard case .endOfBook = sleepTimer else { return }
|
||||||
|
sleepRemainingSeconds = wallclockRemainingUntilEndOfBook()
|
||||||
|
if sleepRemainingSeconds <= 0 {
|
||||||
|
sleepTimer = .off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func wallclockRemainingUntilEndOfBook() -> Double {
|
||||||
|
let playback = max(0, totalDuration - absoluteCurrentTime)
|
||||||
|
let r = max(0.1, Double(rate))
|
||||||
|
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
|
// MARK: - Now-playing / remote commands
|
||||||
|
|
||||||
private func configureRemoteCommandsIfNeeded() {
|
private func configureRemoteCommandsIfNeeded() {
|
||||||
@@ -271,12 +402,24 @@ final class PlayerEngine {
|
|||||||
applyRemoteSkipInterval(seconds: Self.currentSkipSeconds())
|
applyRemoteSkipInterval(seconds: Self.currentSkipSeconds())
|
||||||
center.skipForwardCommand.addTarget { [weak self] _ in
|
center.skipForwardCommand.addTarget { [weak self] _ in
|
||||||
let s = Double(Self.currentSkipSeconds())
|
let s = Double(Self.currentSkipSeconds())
|
||||||
Task { @MainActor in self?.skip(by: s) }
|
Task { @MainActor in
|
||||||
|
if let handler = self?.onRemoteSkip {
|
||||||
|
handler(s)
|
||||||
|
} else {
|
||||||
|
self?.skip(by: s)
|
||||||
|
}
|
||||||
|
}
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
center.skipBackwardCommand.addTarget { [weak self] _ in
|
center.skipBackwardCommand.addTarget { [weak self] _ in
|
||||||
let s = Double(Self.currentSkipSeconds())
|
let s = Double(Self.currentSkipSeconds())
|
||||||
Task { @MainActor in self?.skip(by: -s) }
|
Task { @MainActor in
|
||||||
|
if let handler = self?.onRemoteSkip {
|
||||||
|
handler(-s)
|
||||||
|
} else {
|
||||||
|
self?.skip(by: -s)
|
||||||
|
}
|
||||||
|
}
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
@@ -291,7 +434,13 @@ final class PlayerEngine {
|
|||||||
return .commandFailed
|
return .commandFailed
|
||||||
}
|
}
|
||||||
let target = posEvent.positionTime
|
let target = posEvent.positionTime
|
||||||
Task { @MainActor in self?.seekAbsolute(target) }
|
Task { @MainActor in
|
||||||
|
if let handler = self?.onRemoteSeek {
|
||||||
|
handler(target)
|
||||||
|
} else {
|
||||||
|
self?.seekAbsolute(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
center.changePlaybackRateCommand.supportedPlaybackRates = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
center.changePlaybackRateCommand.supportedPlaybackRates = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@Environment(AppState.self) private var app
|
@Environment(AppState.self) private var app
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@State private var splashVisible = true
|
@State private var splashVisible = true
|
||||||
#endif
|
#endif
|
||||||
@@ -18,6 +19,9 @@ struct ContentView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
.task { await boot() }
|
.task { await boot() }
|
||||||
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
|
if newPhase == .active { app.onScenePhaseActive() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -29,6 +33,7 @@ struct ContentView: View {
|
|||||||
LoginView()
|
LoginView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.environment(\.locale, Locale(identifier: app.language))
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
#else
|
#else
|
||||||
|
|||||||
97
ABS Client/Audiobookshelf swift/Views/FullHistoryView.swift
Normal file
97
ABS Client/Audiobookshelf swift/Views/FullHistoryView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,27 @@ import SwiftUI
|
|||||||
struct LibraryGridView: View {
|
struct LibraryGridView: View {
|
||||||
let items: [LibraryItem]
|
let items: [LibraryItem]
|
||||||
var onRefresh: (() async -> Void)? = nil
|
var onRefresh: (() async -> Void)? = nil
|
||||||
|
var dimDownloading: Bool = false
|
||||||
var onSelect: (LibraryItem) -> Void
|
var onSelect: (LibraryItem) -> Void
|
||||||
|
|
||||||
|
@AppStorage("libraryCoverSize") private var coverSize: Double = Self.defaultCoverSize
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
zoomBar
|
||||||
|
gridContent
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
gridContent
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gridContent: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVGrid(columns: gridColumns, spacing: 8) {
|
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||||
ForEach(items) { item in
|
ForEach(items) { item in
|
||||||
LibraryItemCell(item: item)
|
LibraryItemCell(item: item, dimDownloading: dimDownloading)
|
||||||
.onTapGesture { onSelect(item) }
|
.onTapGesture { onSelect(item) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,14 +39,59 @@ struct LibraryGridView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
private var zoomBar: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "rectangle.grid.3x2")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Slider(value: $coverSize, in: Self.minCoverSize...Self.maxCoverSize)
|
||||||
|
.frame(maxWidth: 220)
|
||||||
|
Image(systemName: "square")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private var gridColumns: [GridItem] {
|
private var gridColumns: [GridItem] {
|
||||||
|
[GridItem(.adaptive(minimum: CGFloat(coverSize)), spacing: gridSpacing)]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gridSpacing: CGFloat {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
// 3 equal columns — compact spacing for full height utilization
|
return 8
|
||||||
[GridItem(.flexible(), spacing: 8),
|
|
||||||
GridItem(.flexible(), spacing: 8),
|
|
||||||
GridItem(.flexible())]
|
|
||||||
#else
|
#else
|
||||||
[GridItem(.adaptive(minimum: 180), spacing: 20)]
|
return 20
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cover-Größen-Defaults (plattformabhängig)
|
||||||
|
|
||||||
|
private static var defaultCoverSize: Double {
|
||||||
|
#if os(iOS)
|
||||||
|
return 110
|
||||||
|
#else
|
||||||
|
return 180
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var minCoverSize: Double {
|
||||||
|
#if os(iOS)
|
||||||
|
return 80
|
||||||
|
#else
|
||||||
|
return 120
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var maxCoverSize: Double {
|
||||||
|
#if os(iOS)
|
||||||
|
return 200
|
||||||
|
#else
|
||||||
|
return 320
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,23 @@ import SwiftUI
|
|||||||
struct LibraryItemCell: View {
|
struct LibraryItemCell: View {
|
||||||
@Environment(AppState.self) private var app
|
@Environment(AppState.self) private var app
|
||||||
let item: LibraryItem
|
let item: LibraryItem
|
||||||
|
var dimDownloading: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
ZStack(alignment: .topTrailing) {
|
ZStack(alignment: .topTrailing) {
|
||||||
cover
|
cover
|
||||||
|
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||||
|
if !(dimDownloading && isActivelyDownloading) {
|
||||||
downloadBadge.padding(4)
|
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))
|
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
|
||||||
.padding(.horizontal, 3)
|
.padding(.horizontal, 3)
|
||||||
.padding(.bottom, 3)
|
.padding(.bottom, 3)
|
||||||
@@ -23,16 +32,38 @@ struct LibraryItemCell: View {
|
|||||||
#endif
|
#endif
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
|
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||||
|
HStack(spacing: 4) {
|
||||||
Text(item.author)
|
Text(item.author)
|
||||||
.font(.system(size: 9))
|
.font(.system(size: 9))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.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
|
// Ensure the cell fills its full grid column width
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contextMenu { downloadMenuItems }
|
.contextMenu { downloadMenuItems }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isActivelyDownloading: Bool {
|
||||||
|
if case .downloading = app.downloads.state(for: item.downloadKey) { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Cover
|
// MARK: - Cover
|
||||||
|
|
||||||
private var cover: some View {
|
private var cover: some View {
|
||||||
@@ -45,17 +76,44 @@ struct LibraryItemCell: View {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private var iOSCover: some View {
|
private var iOSCover: some View {
|
||||||
Color(.systemGray6) // neutral bg for PNG transparent areas
|
coverContainer(background: AnyShapeStyle(Color(.systemGray6)))
|
||||||
.frame(maxWidth: .infinity) // explicitly fill the column width
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
private var macosCover: some View {
|
||||||
|
coverContainer(background: AnyShapeStyle(.quaternary))
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Quadratischer Cover-Container: füllt die Spaltenbreite, behält 1:1-Aspektverhältnis
|
||||||
|
/// und zeigt das Bild **ohne Verzerrung** (`.scaledToFit`). Nicht-quadratische Cover
|
||||||
|
/// bekommen Letterbox-/Pillarbox-Ränder im neutralen Hintergrund.
|
||||||
|
private func coverContainer(background: AnyShapeStyle) -> some View {
|
||||||
|
Rectangle()
|
||||||
|
.fill(background)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
.aspectRatio(1, contentMode: .fit)
|
.aspectRatio(1, contentMode: .fit)
|
||||||
.overlay {
|
.overlay {
|
||||||
if let url = app.client.coverURL(itemId: item.id) {
|
if let url = app.client.coverURL(itemId: item.id) {
|
||||||
AsyncImage(url: url) { image in
|
AsyncImage(url: url) { phase in
|
||||||
image.resizable().scaledToFill()
|
switch phase {
|
||||||
} placeholder: {
|
case .success(let img):
|
||||||
ProgressView().tint(.accentColor)
|
img.resizable().scaledToFit()
|
||||||
|
case .empty:
|
||||||
|
ProgressView()
|
||||||
|
#if os(macOS)
|
||||||
|
.controlSize(.small)
|
||||||
|
#else
|
||||||
|
.tint(.accentColor)
|
||||||
|
#endif
|
||||||
|
case .failure:
|
||||||
|
Image(systemName: "book.closed")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
@unknown default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.clipped() // clip image overflow before rounding
|
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "book.closed")
|
Image(systemName: "book.closed")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -63,34 +121,6 @@ struct LibraryItemCell: View {
|
|||||||
}
|
}
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
private var macosCover: some View {
|
|
||||||
Group {
|
|
||||||
if let url = app.client.coverURL(itemId: item.id) {
|
|
||||||
AsyncImage(url: url) { phase in
|
|
||||||
switch phase {
|
|
||||||
case .empty:
|
|
||||||
Rectangle().fill(.quaternary)
|
|
||||||
.overlay(ProgressView().controlSize(.small))
|
|
||||||
case .success(let img):
|
|
||||||
img.resizable().aspectRatio(contentMode: .fill)
|
|
||||||
case .failure:
|
|
||||||
Rectangle().fill(.quaternary)
|
|
||||||
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
|
|
||||||
@unknown default:
|
|
||||||
Rectangle().fill(.quaternary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Rectangle().fill(.quaternary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 180, height: 180)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// MARK: - Download badge
|
// MARK: - Download badge
|
||||||
|
|
||||||
@@ -199,3 +229,40 @@ struct DownloadProgressRing: View {
|
|||||||
.shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 1)
|
.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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ enum LibraryLayout: String, CaseIterable, Identifiable {
|
|||||||
struct LibraryListView: View {
|
struct LibraryListView: View {
|
||||||
let items: [LibraryItem]
|
let items: [LibraryItem]
|
||||||
var onRefresh: (() async -> Void)? = nil
|
var onRefresh: (() async -> Void)? = nil
|
||||||
|
var dimDownloading: Bool = false
|
||||||
let onSelect: (LibraryItem) -> Void
|
let onSelect: (LibraryItem) -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
List {
|
List {
|
||||||
ForEach(items) { item in
|
ForEach(items) { item in
|
||||||
LibraryListRow(item: item)
|
LibraryListRow(item: item, dimDownloading: dimDownloading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onSelect(item) }
|
.onTapGesture { onSelect(item) }
|
||||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||||
@@ -32,7 +33,7 @@ struct LibraryListView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) {
|
||||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
||||||
LibraryListRow(item: item)
|
LibraryListRow(item: item, dimDownloading: dimDownloading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onSelect(item) }
|
.onTapGesture { onSelect(item) }
|
||||||
if idx < items.count - 1 {
|
if idx < items.count - 1 {
|
||||||
@@ -49,10 +50,17 @@ struct LibraryListView: View {
|
|||||||
struct LibraryListRow: View {
|
struct LibraryListRow: View {
|
||||||
@Environment(AppState.self) private var app
|
@Environment(AppState.self) private var app
|
||||||
let item: LibraryItem
|
let item: LibraryItem
|
||||||
|
var dimDownloading: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
cover
|
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) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(item.title)
|
Text(item.title)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -77,19 +85,28 @@ struct LibraryListRow: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
#if os(macOS)
|
#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))
|
Text(formatDuration(item.durationSeconds))
|
||||||
.font(.caption.monospacedDigit())
|
.font(.caption.monospacedDigit())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
if !(dimDownloading && isActivelyDownloading) {
|
||||||
downloadStatus
|
downloadStatus
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(width: 28)
|
.frame(width: 28)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -97,6 +114,11 @@ struct LibraryListRow: View {
|
|||||||
.contextMenu { downloadMenuItems }
|
.contextMenu { downloadMenuItems }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var isActivelyDownloading: Bool {
|
||||||
|
if case .downloading = app.downloads.state(for: item.downloadKey) { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private var cover: some View {
|
private var cover: some View {
|
||||||
Group {
|
Group {
|
||||||
if let url = app.client.coverURL(itemId: item.id) {
|
if let url = app.client.coverURL(itemId: item.id) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
enum LibraryFilter: Hashable {
|
enum LibraryFilter: Hashable {
|
||||||
case library(String)
|
case library(String)
|
||||||
case downloaded
|
case downloaded
|
||||||
|
case history
|
||||||
}
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
@@ -15,8 +16,6 @@ final class LibraryViewModel {
|
|||||||
var selection: LibraryFilter?
|
var selection: LibraryFilter?
|
||||||
|
|
||||||
func loadLibraries(client: ABSClient) async {
|
func loadLibraries(client: ABSClient) async {
|
||||||
isLoading = true
|
|
||||||
defer { isLoading = false }
|
|
||||||
do {
|
do {
|
||||||
libraries = try await client.fetchLibraries()
|
libraries = try await client.fetchLibraries()
|
||||||
if selection == nil, let first = libraries.first {
|
if selection == nil, let first = libraries.first {
|
||||||
@@ -29,8 +28,6 @@ final class LibraryViewModel {
|
|||||||
|
|
||||||
func loadItems(client: ABSClient, downloads: DownloadManager) async {
|
func loadItems(client: ABSClient, downloads: DownloadManager) async {
|
||||||
guard let selection else { return }
|
guard let selection else { return }
|
||||||
isLoading = true
|
|
||||||
defer { isLoading = false }
|
|
||||||
switch selection {
|
switch selection {
|
||||||
case .library(let id):
|
case .library(let id):
|
||||||
do {
|
do {
|
||||||
@@ -39,8 +36,11 @@ final class LibraryViewModel {
|
|||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
case .history:
|
||||||
|
items = []
|
||||||
|
errorMessage = nil
|
||||||
case .downloaded:
|
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
|
let files: [AudioFile] = di.tracks.enumerated().map { idx, t in
|
||||||
AudioFile(
|
AudioFile(
|
||||||
ino: t.ino,
|
ino: t.ino,
|
||||||
@@ -62,7 +62,12 @@ final class LibraryViewModel {
|
|||||||
li.episodeId = episodeId
|
li.episodeId = episodeId
|
||||||
}
|
}
|
||||||
return li
|
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
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,6 +78,8 @@ struct MainView: View {
|
|||||||
@State private var vm = LibraryViewModel()
|
@State private var vm = LibraryViewModel()
|
||||||
@State private var navPath: [LibraryItem] = []
|
@State private var navPath: [LibraryItem] = []
|
||||||
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
@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)
|
#if os(iOS)
|
||||||
@State private var showSettings: Bool = false
|
@State private var showSettings: Bool = false
|
||||||
#endif
|
#endif
|
||||||
@@ -90,11 +97,25 @@ struct MainView: View {
|
|||||||
navPath.removeAll()
|
navPath.removeAll()
|
||||||
Task { await loadAll() }
|
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) {
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||||
PlayerBar()
|
PlayerBar()
|
||||||
.animation(.easeInOut(duration: 0.2), value: app.currentItem?.id)
|
.animation(.easeInOut(duration: 0.2), value: app.currentItem?.id)
|
||||||
.animation(.easeInOut(duration: 0.2), value: app.isPreparingPlayback)
|
.animation(.easeInOut(duration: 0.2), value: app.isPreparingPlayback)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showFullHistory) {
|
||||||
|
FullHistoryView()
|
||||||
|
.environment(app)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -115,6 +136,14 @@ struct MainView: View {
|
|||||||
Label(l.label, systemImage: l.systemImage).tag(l.rawValue)
|
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()
|
Divider()
|
||||||
Button {
|
Button {
|
||||||
showSettings = true
|
showSettings = true
|
||||||
@@ -151,8 +180,14 @@ struct MainView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadAll() async {
|
private func loadAll() async {
|
||||||
|
vm.items = []
|
||||||
|
vm.isLoading = true
|
||||||
|
defer { vm.isLoading = false }
|
||||||
|
|
||||||
await vm.loadLibraries(client: app.client)
|
await vm.loadLibraries(client: app.client)
|
||||||
|
if vm.selection != .history {
|
||||||
await vm.loadItems(client: app.client, downloads: app.downloads)
|
await vm.loadItems(client: app.client, downloads: app.downloads)
|
||||||
|
}
|
||||||
await app.refreshProgressCache()
|
await app.refreshProgressCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,74 +204,201 @@ struct MainView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
private var sidebar: some View {
|
private var sidebar: some View {
|
||||||
List(selection: $vm.selection) {
|
List(selection: $vm.selection) {
|
||||||
Section("Bibliotheken") {
|
Section(String(localized: "sidebar.libraries")) {
|
||||||
ForEach(vm.libraries) { lib in
|
ForEach(vm.libraries) { lib in
|
||||||
Label(lib.name, systemImage: "books.vertical")
|
Label(lib.name, systemImage: "books.vertical")
|
||||||
.tag(LibraryFilter.library(lib.id))
|
.tag(LibraryFilter.library(lib.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("Offline") {
|
Section(String(localized: "sidebar.offline")) {
|
||||||
Label("Heruntergeladen", systemImage: "arrow.down.circle.fill")
|
Label(String(localized: "nav.downloaded"), systemImage: "arrow.down.circle.fill")
|
||||||
.tag(LibraryFilter.downloaded)
|
.tag(LibraryFilter.downloaded)
|
||||||
}
|
}
|
||||||
|
if historyEnabled {
|
||||||
|
Section(String(localized: "nav.history")) {
|
||||||
|
Label(String(localized: "sidebar.history"), systemImage: "clock.arrow.circlepath")
|
||||||
|
.tag(LibraryFilter.history)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
.navigationTitle("ABS Client")
|
.navigationTitle(String(localized: "sidebar.app_title"))
|
||||||
.safeAreaInset(edge: .bottom) {
|
.safeAreaInset(edge: .bottom) {
|
||||||
sidebarFooter
|
sidebarFooter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var sidebarFooter: some View {
|
private var sidebarFooter: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(spacing: 0) {
|
||||||
Divider()
|
Divider()
|
||||||
HStack(spacing: 8) {
|
VStack(spacing: 0) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(app.network.isOnline ? .green : .orange)
|
.fill(app.network.isOnline ? Color.green : Color.orange)
|
||||||
.frame(width: 8, height: 8)
|
.frame(width: 6, height: 6)
|
||||||
Text(app.network.isOnline ? "Online" : "Offline")
|
Text(app.network.isOnline
|
||||||
.font(.caption)
|
? String(localized: "sidebar.status_online")
|
||||||
|
: String(localized: "sidebar.status_offline"))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
if app.sync.queuedCount > 0 {
|
if app.sync.queuedCount > 0 {
|
||||||
Text("(\(app.sync.queuedCount) wartend)")
|
Text("· \(app.sync.queuedCount)")
|
||||||
.font(.caption)
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "person.circle")
|
||||||
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
Text(app.auth.username)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
Button {
|
||||||
HStack {
|
|
||||||
Text(app.auth.username).font(.caption).foregroundStyle(.secondary)
|
|
||||||
Spacer()
|
|
||||||
Button("Abmelden") {
|
|
||||||
app.stopPlayback()
|
app.stopPlayback()
|
||||||
app.auth.logout()
|
app.auth.logout()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
.font(.caption)
|
.help(String(localized: "settings.logout"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
#endif
|
||||||
|
|
||||||
// MARK: - Detail content (shared)
|
// MARK: - Detail content
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var detail: some View {
|
private var detail: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
if vm.selection == .history {
|
||||||
|
historyDetailContent
|
||||||
|
.navigationTitle(String(localized: "sidebar.history"))
|
||||||
|
} else {
|
||||||
|
libraryContent
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
libraryContent
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var libraryContent: some View {
|
||||||
|
ZStack {
|
||||||
if vm.isLoading && vm.items.isEmpty {
|
if vm.isLoading && vm.items.isEmpty {
|
||||||
ProgressView("Lade Bibliothek …")
|
ProgressView("Lade Bibliothek …")
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.transition(.opacity)
|
||||||
} else if let err = vm.errorMessage, vm.items.isEmpty {
|
} else if let err = vm.errorMessage, vm.items.isEmpty {
|
||||||
ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err))
|
ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err))
|
||||||
|
.transition(.opacity)
|
||||||
} else if vm.items.isEmpty {
|
} else if vm.items.isEmpty {
|
||||||
ContentUnavailableView("Keine Hörbücher", systemImage: "books.vertical", description: Text("Diese Auswahl enthält noch keine Hörbücher."))
|
ContentUnavailableView("Keine Hörbücher", systemImage: "books.vertical", description: Text("Diese Auswahl enthält noch keine Hörbücher."))
|
||||||
|
.transition(.opacity)
|
||||||
} else {
|
} 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 {
|
Group {
|
||||||
|
let isDownloaded = vm.selection == .downloaded
|
||||||
switch layout {
|
switch layout {
|
||||||
case .grid:
|
case .grid:
|
||||||
LibraryGridView(items: vm.items, onRefresh: loadAll) { handleSelect($0) }
|
LibraryGridView(items: vm.items, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) }
|
||||||
case .list:
|
case .list:
|
||||||
LibraryListView(items: vm.items, onRefresh: loadAll) { handleSelect($0) }
|
LibraryListView(items: vm.items, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@@ -269,7 +431,6 @@ struct MainView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - iOS-only helpers
|
// MARK: - iOS-only helpers
|
||||||
|
|
||||||
@@ -319,6 +480,7 @@ struct MainView: View {
|
|||||||
private var selectionIcon: String {
|
private var selectionIcon: String {
|
||||||
switch vm.selection {
|
switch vm.selection {
|
||||||
case .downloaded: return "arrow.down.circle.fill"
|
case .downloaded: return "arrow.down.circle.fill"
|
||||||
|
case .history: return "clock.arrow.circlepath"
|
||||||
default: return "books.vertical"
|
default: return "books.vertical"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,6 +494,8 @@ struct MainView: View {
|
|||||||
return vm.libraries.first(where: { $0.id == id })?.name ?? "Bibliothek"
|
return vm.libraries.first(where: { $0.id == id })?.name ?? "Bibliothek"
|
||||||
case .downloaded:
|
case .downloaded:
|
||||||
return "Heruntergeladen"
|
return "Heruntergeladen"
|
||||||
|
case .history:
|
||||||
|
return String(localized: "sidebar.history")
|
||||||
case .none:
|
case .none:
|
||||||
return "Bibliothek"
|
return "Bibliothek"
|
||||||
}
|
}
|
||||||
|
|||||||
245
ABS Client/Audiobookshelf swift/Views/PlaybackDetailsView.swift
Normal file
245
ABS Client/Audiobookshelf swift/Views/PlaybackDetailsView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -3,8 +3,12 @@ import SwiftUI
|
|||||||
struct PlayerBar: View {
|
struct PlayerBar: View {
|
||||||
@Environment(AppState.self) private var app
|
@Environment(AppState.self) private var app
|
||||||
@AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
|
@AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
|
||||||
|
@AppStorage("historyEnabled") private var historyEnabled: Bool = false
|
||||||
@State private var scrubbing: Bool = false
|
@State private var scrubbing: Bool = false
|
||||||
@State private var scrubValue: Double = 0
|
@State private var scrubValue: Double = 0
|
||||||
|
@State private var showDetails: Bool = false
|
||||||
|
@State private var showFullHistory: Bool = false
|
||||||
|
@State private var showResumePrompt: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if let item = app.currentItem {
|
if let item = app.currentItem {
|
||||||
@@ -21,6 +25,24 @@ struct PlayerBar: View {
|
|||||||
.background(.bar)
|
.background(.bar)
|
||||||
}
|
}
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
.sheet(isPresented: $showDetails) {
|
||||||
|
PlaybackDetailsView()
|
||||||
|
.environment(app)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showFullHistory) {
|
||||||
|
FullHistoryView()
|
||||||
|
.environment(app)
|
||||||
|
}
|
||||||
|
.alert("Neuerer Stand auf dem Server", isPresented: $showResumePrompt, presenting: app.pendingServerProgress) { server in
|
||||||
|
Button("Hier weiter (\(formatTime(app.player.absoluteCurrentTime)))") {
|
||||||
|
app.dismissPendingServerProgress()
|
||||||
|
}
|
||||||
|
Button("Server-Stand übernehmen (\(formatTime(server.currentTime)))") {
|
||||||
|
app.acceptPendingServerProgress()
|
||||||
|
}
|
||||||
|
} message: { server in
|
||||||
|
Text("Auf einem anderen Gerät wurde dieses Hörbuch bis \(formatTime(server.currentTime)) gehört.")
|
||||||
|
}
|
||||||
} else if app.isPreparingPlayback {
|
} else if app.isPreparingPlayback {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Divider()
|
Divider()
|
||||||
@@ -53,7 +75,7 @@ struct PlayerBar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Button { app.togglePlay() } label: {
|
Button { handlePlayTap() } label: {
|
||||||
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||||
.font(.system(size: 36))
|
.font(.system(size: 36))
|
||||||
}
|
}
|
||||||
@@ -62,7 +84,7 @@ struct PlayerBar: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scrubber
|
scrubber
|
||||||
HStack(spacing: 24) {
|
HStack(spacing: 20) {
|
||||||
Button { app.skip(by: -Double(skipSeconds)) } label: {
|
Button { app.skip(by: -Double(skipSeconds)) } label: {
|
||||||
Image(systemName: skipBackImage).font(.system(size: 22))
|
Image(systemName: skipBackImage).font(.system(size: 22))
|
||||||
}
|
}
|
||||||
@@ -77,6 +99,19 @@ struct PlayerBar: View {
|
|||||||
|
|
||||||
Spacer()
|
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
|
rateMenu
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@@ -113,8 +148,21 @@ struct PlayerBar: View {
|
|||||||
|
|
||||||
rateMenu
|
rateMenu
|
||||||
|
|
||||||
|
sleepMenu
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
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
|
statusIndicator
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@@ -136,7 +184,7 @@ struct PlayerBar: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(!app.player.isReady)
|
.disabled(!app.player.isReady)
|
||||||
|
|
||||||
Button { app.togglePlay() } label: {
|
Button { handlePlayTap() } label: {
|
||||||
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
|
||||||
.font(.system(size: 34))
|
.font(.system(size: 34))
|
||||||
}
|
}
|
||||||
@@ -194,30 +242,110 @@ struct PlayerBar: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var scrubber: some View {
|
private var scrubber: some View {
|
||||||
VStack(spacing: 2) {
|
ScrubberView(scrubbing: $scrubbing, scrubValue: $scrubValue)
|
||||||
Slider(
|
}
|
||||||
value: Binding(
|
|
||||||
get: { scrubbing ? scrubValue : app.player.absoluteCurrentTime },
|
private var detailsButtonVisible: Bool {
|
||||||
set: { scrubValue = $0; scrubbing = true }
|
app.player.isReady
|
||||||
),
|
}
|
||||||
in: 0...max(app.player.totalDuration, 1),
|
|
||||||
onEditingChanged: { editing in
|
private var historyQuickMenu: some View {
|
||||||
if !editing {
|
Menu {
|
||||||
app.seekAbsolute(scrubValue)
|
let recent = Array(app.history.entries
|
||||||
scrubbing = false
|
.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)
|
||||||
|
Divider()
|
||||||
|
sleepOption(title: "10 Minuten", mode: .minutes(10))
|
||||||
|
sleepOption(title: "20 Minuten", mode: .minutes(20))
|
||||||
|
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)
|
||||||
|
.font(.system(size: 22))
|
||||||
|
#else
|
||||||
|
.font(.system(size: 16))
|
||||||
|
#endif
|
||||||
|
.foregroundStyle(app.player.sleepTimer == .off ? Color.secondary : Color.accentColor)
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.fixedSize()
|
||||||
|
#endif
|
||||||
|
.menuIndicator(.hidden)
|
||||||
|
.help("Sleep-Timer")
|
||||||
.disabled(!app.player.isReady)
|
.disabled(!app.player.isReady)
|
||||||
HStack {
|
|
||||||
Text(formatTime(scrubbing ? scrubValue : app.player.absoluteCurrentTime))
|
|
||||||
.font(.caption2.monospacedDigit())
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Spacer()
|
|
||||||
Text(formatTime(app.player.totalDuration))
|
|
||||||
.font(.caption2.monospacedDigit())
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func sleepOption(title: String, mode: SleepTimerMode) -> some View {
|
||||||
|
Button {
|
||||||
|
app.player.setSleepTimer(mode)
|
||||||
|
} label: {
|
||||||
|
if app.player.sleepTimer == mode {
|
||||||
|
Label(title, systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var endOfPlaybackLabel: String {
|
||||||
|
app.currentItem?.isPodcast == true
|
||||||
|
? "Bis Ende der Folge"
|
||||||
|
: "Bis Ende des Hörbuchs"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Intercept Play to surface the resume-prompt if another device pushed a
|
||||||
|
/// newer position while we were paused/playing.
|
||||||
|
private func handlePlayTap() {
|
||||||
|
if !app.player.isPlaying, app.pendingServerProgress != nil {
|
||||||
|
showResumePrompt = true
|
||||||
|
} else {
|
||||||
|
app.togglePlay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,3 +410,71 @@ struct PlayerBar: View {
|
|||||||
return String(format: "%d:%02d", m, s)
|
return String(format: "%d:%02d", m, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extracted into its own View so that per-second updates to
|
||||||
|
/// `player.absoluteCurrentTime` / `sleepRemainingSeconds` only re-render this
|
||||||
|
/// subtree — not the entire `PlayerBar` (which would also rebuild open menus,
|
||||||
|
/// causing visible flicker on iOS).
|
||||||
|
private struct ScrubberView: View {
|
||||||
|
@Environment(AppState.self) private var app
|
||||||
|
@Binding var scrubbing: Bool
|
||||||
|
@Binding var scrubValue: Double
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Slider(
|
||||||
|
value: Binding(
|
||||||
|
get: { scrubbing ? scrubValue : app.player.absoluteCurrentTime },
|
||||||
|
set: { scrubValue = $0; scrubbing = true }
|
||||||
|
),
|
||||||
|
in: 0...max(app.player.totalDuration, 1),
|
||||||
|
onEditingChanged: { editing in
|
||||||
|
if !editing {
|
||||||
|
app.seekAbsolute(scrubValue)
|
||||||
|
scrubbing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.disabled(!app.player.isReady)
|
||||||
|
HStack {
|
||||||
|
Text(formatTime(scrubbing ? scrubValue : app.player.absoluteCurrentTime))
|
||||||
|
.font(.caption2.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(formatTime(app.player.totalDuration))
|
||||||
|
.font(.caption2.monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if app.player.sleepTimer != .off {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "moon.zzz.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
Text("\(formatTime(app.player.sleepRemainingSeconds)) · endet \(formatWallTime(sleepEndsAt))")
|
||||||
|
.font(.caption2.monospacedDigit())
|
||||||
|
}
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sleepEndsAt: Date {
|
||||||
|
Date().addingTimeInterval(app.player.sleepRemainingSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatWallTime(_ date: Date) -> String {
|
||||||
|
date.formatted(.dateTime.hour().minute())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatTime(_ seconds: Double) -> String {
|
||||||
|
guard seconds.isFinite, seconds >= 0 else { return "0:00" }
|
||||||
|
let total = Int(seconds)
|
||||||
|
let h = total / 3600
|
||||||
|
let m = (total % 3600) / 60
|
||||||
|
let s = total % 60
|
||||||
|
if h > 0 {
|
||||||
|
return String(format: "%d:%02d:%02d", h, m, s)
|
||||||
|
}
|
||||||
|
return String(format: "%d:%02d", m, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ struct SettingsView: View {
|
|||||||
@AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
|
@AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
|
||||||
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
||||||
@AppStorage("autoRefreshOnLaunch") private var autoRefreshOnLaunch: Bool = true
|
@AppStorage("autoRefreshOnLaunch") private var autoRefreshOnLaunch: Bool = true
|
||||||
|
@AppStorage("historyEnabled") private var historyEnabled: Bool = false
|
||||||
|
|
||||||
@State private var showLogoutConfirm: 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]
|
private static let skipOptions: [Int] = [10, 15, 30, 45, 60, 90]
|
||||||
|
|
||||||
@@ -20,6 +23,7 @@ struct SettingsView: View {
|
|||||||
Form {
|
Form {
|
||||||
connectionSection
|
connectionSection
|
||||||
playbackSection
|
playbackSection
|
||||||
|
historySection
|
||||||
appearanceSection
|
appearanceSection
|
||||||
downloadsSection
|
downloadsSection
|
||||||
aboutSection
|
aboutSection
|
||||||
@@ -45,6 +49,24 @@ struct SettingsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("Du wirst zurück zur Login-Maske geschickt. Heruntergeladene Inhalte bleiben.")
|
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
|
#else
|
||||||
TabView {
|
TabView {
|
||||||
@@ -54,6 +76,9 @@ struct SettingsView: View {
|
|||||||
playbackPane
|
playbackPane
|
||||||
.tabItem { Label("Wiedergabe", systemImage: "play.circle") }
|
.tabItem { Label("Wiedergabe", systemImage: "play.circle") }
|
||||||
|
|
||||||
|
historyPane
|
||||||
|
.tabItem { Label("Verlauf", systemImage: "clock.arrow.circlepath") }
|
||||||
|
|
||||||
appearancePane
|
appearancePane
|
||||||
.tabItem { Label("Darstellung", systemImage: "square.grid.2x2") }
|
.tabItem { Label("Darstellung", systemImage: "square.grid.2x2") }
|
||||||
|
|
||||||
@@ -61,7 +86,7 @@ struct SettingsView: View {
|
|||||||
.tabItem { Label("Über", systemImage: "info.circle") }
|
.tabItem { Label("Über", systemImage: "info.circle") }
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
.frame(width: 480, height: 320)
|
.frame(width: 480, height: 360)
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Mit Server abmelden?",
|
"Mit Server abmelden?",
|
||||||
isPresented: $showLogoutConfirm,
|
isPresented: $showLogoutConfirm,
|
||||||
@@ -75,6 +100,19 @@ struct SettingsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("Du wirst zur Login-Maske zurückgesetzt. Heruntergeladene Hörbücher bleiben erhalten.")
|
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
|
#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 {
|
private var appearanceSection: some View {
|
||||||
Section {
|
Section {
|
||||||
Picker("Bibliotheks-Ansicht", selection: $layoutRaw) {
|
Picker("Bibliotheks-Ansicht", selection: $layoutRaw) {
|
||||||
@@ -138,6 +211,10 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Toggle("Beim Start automatisch aktualisieren", isOn: $autoRefreshOnLaunch)
|
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: {
|
} header: {
|
||||||
Text("Darstellung")
|
Text("Darstellung")
|
||||||
}
|
}
|
||||||
@@ -219,6 +296,38 @@ struct SettingsView: View {
|
|||||||
.formStyle(.grouped)
|
.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 {
|
private var appearancePane: some View {
|
||||||
Form {
|
Form {
|
||||||
Picker("Bibliotheks-Ansicht", selection: $layoutRaw) {
|
Picker("Bibliotheks-Ansicht", selection: $layoutRaw) {
|
||||||
@@ -227,6 +336,10 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Toggle("Beim Start automatisch aktualisieren", isOn: $autoRefreshOnLaunch)
|
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)
|
.formStyle(.grouped)
|
||||||
}
|
}
|
||||||
@@ -248,3 +361,15 @@ struct SettingsView: View {
|
|||||||
return "\(v) (\(b))"
|
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
|
||||||
|
|||||||
152
ABS Client/Audiobookshelf swift/de.lproj/Localizable.strings
Normal file
152
ABS Client/Audiobookshelf swift/de.lproj/Localizable.strings
Normal 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 %@";
|
||||||
152
ABS Client/Audiobookshelf swift/en.lproj/Localizable.strings
Normal file
152
ABS Client/Audiobookshelf swift/en.lproj/Localizable.strings
Normal 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 %@";
|
||||||
@@ -40,11 +40,18 @@
|
|||||||
<key>UIColorName</key>
|
<key>UIColorName</key>
|
||||||
<string>LaunchBackground</string>
|
<string>LaunchBackground</string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<key>UISupportedInterfaceOrientations~iphone</key>
|
||||||
<array>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
56
Exports/Mac/ABS Client.app/Contents/Info.plist
Normal file
56
Exports/Mac/ABS Client.app/Contents/Info.plist
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>BuildMachineOSBuild</key>
|
||||||
|
<string>25F71</string>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>ABS Client</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>ABS Client</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>CFBundleIconName</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.local.ABS-Client</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>ABS Client</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleSupportedPlatforms</key>
|
||||||
|
<array>
|
||||||
|
<string>MacOSX</string>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>DTCompiler</key>
|
||||||
|
<string>com.apple.compilers.llvm.clang.1_0</string>
|
||||||
|
<key>DTPlatformBuild</key>
|
||||||
|
<string>25F70</string>
|
||||||
|
<key>DTPlatformName</key>
|
||||||
|
<string>macosx</string>
|
||||||
|
<key>DTPlatformVersion</key>
|
||||||
|
<string>26.5</string>
|
||||||
|
<key>DTSDKBuild</key>
|
||||||
|
<string>25F70</string>
|
||||||
|
<key>DTSDKName</key>
|
||||||
|
<string>macosx26.5</string>
|
||||||
|
<key>DTXcode</key>
|
||||||
|
<string>2650</string>
|
||||||
|
<key>DTXcodeBuild</key>
|
||||||
|
<string>17F42</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.books</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>26.0</string>
|
||||||
|
<key>NSAccentColorName</key>
|
||||||
|
<string>AccentColor</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
BIN
Exports/Mac/ABS Client.app/Contents/MacOS/ABS Client
Executable file
BIN
Exports/Mac/ABS Client.app/Contents/MacOS/ABS Client
Executable file
Binary file not shown.
1
Exports/Mac/ABS Client.app/Contents/PkgInfo
Normal file
1
Exports/Mac/ABS Client.app/Contents/PkgInfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
APPL????
|
||||||
BIN
Exports/Mac/ABS Client.app/Contents/Resources/AppIcon.icns
Normal file
BIN
Exports/Mac/ABS Client.app/Contents/Resources/AppIcon.icns
Normal file
Binary file not shown.
BIN
Exports/Mac/ABS Client.app/Contents/Resources/Assets.car
Normal file
BIN
Exports/Mac/ABS Client.app/Contents/Resources/Assets.car
Normal file
Binary file not shown.
139
Exports/Mac/ABS Client.app/Contents/_CodeSignature/CodeResources
Normal file
139
Exports/Mac/ABS Client.app/Contents/_CodeSignature/CodeResources
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>files</key>
|
||||||
|
<dict>
|
||||||
|
<key>Resources/AppIcon.icns</key>
|
||||||
|
<data>
|
||||||
|
PIslMkIS+dR91jAoGiXhG5ZQxNw=
|
||||||
|
</data>
|
||||||
|
<key>Resources/Assets.car</key>
|
||||||
|
<data>
|
||||||
|
q6mmhkyHvvRXVcw9rIIm/MbqTqI=
|
||||||
|
</data>
|
||||||
|
</dict>
|
||||||
|
<key>files2</key>
|
||||||
|
<dict>
|
||||||
|
<key>Resources/AppIcon.icns</key>
|
||||||
|
<dict>
|
||||||
|
<key>hash2</key>
|
||||||
|
<data>
|
||||||
|
UmgGZpb6aN5Xnz7XswrsNpMxnVcQINgSUpIhHWS1GBM=
|
||||||
|
</data>
|
||||||
|
</dict>
|
||||||
|
<key>Resources/Assets.car</key>
|
||||||
|
<dict>
|
||||||
|
<key>hash2</key>
|
||||||
|
<data>
|
||||||
|
lfMPkDEQ0MA2q3+uXr1255juM39IkgqKOiK8Kede+X0=
|
||||||
|
</data>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>rules</key>
|
||||||
|
<dict>
|
||||||
|
<key>^Resources/</key>
|
||||||
|
<true/>
|
||||||
|
<key>^Resources/.*\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>optional</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1000</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1100</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/Base\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1010</real>
|
||||||
|
</dict>
|
||||||
|
<key>^version.plist$</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>rules2</key>
|
||||||
|
<dict>
|
||||||
|
<key>.*\.dSYM($|/)</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>11</real>
|
||||||
|
</dict>
|
||||||
|
<key>^(.*/)?\.DS_Store$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>2000</real>
|
||||||
|
</dict>
|
||||||
|
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
||||||
|
<dict>
|
||||||
|
<key>nested</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>10</real>
|
||||||
|
</dict>
|
||||||
|
<key>^.*</key>
|
||||||
|
<true/>
|
||||||
|
<key>^Info\.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^PkgInfo$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/.*\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>optional</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1000</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1100</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/Base\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1010</real>
|
||||||
|
</dict>
|
||||||
|
<key>^[^/]+$</key>
|
||||||
|
<dict>
|
||||||
|
<key>nested</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>10</real>
|
||||||
|
</dict>
|
||||||
|
<key>^embedded\.provisionprofile$</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^version\.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Binary file not shown.
@@ -1,9 +1,2 @@
|
|||||||
create-dmg \
|
create-dmg --volname "ABS Client" --window-size 600 400 --icon-size 100 --icon "ABS Client.app" 150 200 --app-drop-link 450 200 ~/ABS-Client/Exports/Mac/ABS-Client.dmg 'ABS Client.app'
|
||||||
--volname "ABS-Client" \
|
|
||||||
--window-size 600 400 \
|
|
||||||
--icon-size 100 \
|
|
||||||
--icon "ABS-Client.app" 150 200 \
|
|
||||||
--app-drop-link 450 200 \
|
|
||||||
~/ABS-Client/Exports/Mac/ABS-Client.dmg \
|
|
||||||
ABS Client.app
|
|
||||||
≈
|
|
||||||
|
|||||||
Reference in New Issue
Block a user