Version 1.1 App Store
This commit is contained in:
@@ -5,9 +5,22 @@ struct LibraryGridView: View {
|
||||
var onRefresh: (() async -> Void)? = nil
|
||||
var onSelect: (LibraryItem) -> Void
|
||||
|
||||
@AppStorage("libraryCoverSize") private var coverSize: Double = Self.defaultCoverSize
|
||||
|
||||
var body: some View {
|
||||
#if os(macOS)
|
||||
VStack(spacing: 0) {
|
||||
zoomBar
|
||||
gridContent
|
||||
}
|
||||
#else
|
||||
gridContent
|
||||
#endif
|
||||
}
|
||||
|
||||
private var gridContent: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: gridColumns, spacing: 8) {
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(items) { item in
|
||||
LibraryItemCell(item: item)
|
||||
.onTapGesture { onSelect(item) }
|
||||
@@ -25,14 +38,59 @@ struct LibraryGridView: View {
|
||||
#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] {
|
||||
[GridItem(.adaptive(minimum: CGFloat(coverSize)), spacing: gridSpacing)]
|
||||
}
|
||||
|
||||
private var gridSpacing: CGFloat {
|
||||
#if os(iOS)
|
||||
// 3 equal columns — compact spacing for full height utilization
|
||||
[GridItem(.flexible(), spacing: 8),
|
||||
GridItem(.flexible(), spacing: 8),
|
||||
GridItem(.flexible())]
|
||||
return 8
|
||||
#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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,17 +45,44 @@ struct LibraryItemCell: View {
|
||||
|
||||
#if os(iOS)
|
||||
private var iOSCover: some View {
|
||||
Color(.systemGray6) // neutral bg for PNG transparent areas
|
||||
.frame(maxWidth: .infinity) // explicitly fill the column width
|
||||
coverContainer(background: AnyShapeStyle(Color(.systemGray6)))
|
||||
}
|
||||
#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)
|
||||
.overlay {
|
||||
if let url = app.client.coverURL(itemId: item.id) {
|
||||
AsyncImage(url: url) { image in
|
||||
image.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
ProgressView().tint(.accentColor)
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .success(let img):
|
||||
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 {
|
||||
Image(systemName: "book.closed")
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -63,34 +90,6 @@ struct LibraryItemCell: View {
|
||||
}
|
||||
.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
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ struct PlayerBar: View {
|
||||
}
|
||||
|
||||
scrubber
|
||||
HStack(spacing: 24) {
|
||||
HStack(spacing: 20) {
|
||||
Button { app.skip(by: -Double(skipSeconds)) } label: {
|
||||
Image(systemName: skipBackImage).font(.system(size: 22))
|
||||
}
|
||||
@@ -77,6 +77,8 @@ struct PlayerBar: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
sleepMenu
|
||||
|
||||
rateMenu
|
||||
|
||||
Button {
|
||||
@@ -113,6 +115,8 @@ struct PlayerBar: View {
|
||||
|
||||
rateMenu
|
||||
|
||||
sleepMenu
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
statusIndicator
|
||||
@@ -218,9 +222,73 @@ struct PlayerBar: View {
|
||||
.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 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)
|
||||
} 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)
|
||||
}
|
||||
|
||||
@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 sleepEndsAt: Date {
|
||||
Date().addingTimeInterval(app.player.sleepRemainingSeconds)
|
||||
}
|
||||
|
||||
private var endOfPlaybackLabel: String {
|
||||
app.currentItem?.isPodcast == true
|
||||
? "Bis Ende der Folge"
|
||||
: "Bis Ende des Hörbuchs"
|
||||
}
|
||||
|
||||
private func formatWallTime(_ date: Date) -> String {
|
||||
date.formatted(.dateTime.hour().minute())
|
||||
}
|
||||
|
||||
private var rateMenu: some View {
|
||||
Menu {
|
||||
ForEach([0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { r in
|
||||
|
||||
Reference in New Issue
Block a user