Merge iOS and Mac app into one

This commit is contained in:
Scarriffle
2026-05-17 21:06:59 +02:00
parent 069f8bac2d
commit ac7906f0cf
72 changed files with 1277 additions and 3408 deletions

View File

@@ -0,0 +1,284 @@
import SwiftUI
struct PlayerBar: View {
@Environment(AppState.self) private var app
@AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
@State private var scrubbing: Bool = false
@State private var scrubValue: Double = 0
var body: some View {
if let item = app.currentItem {
VStack(spacing: 0) {
Divider()
content(item: item)
.padding(.horizontal, 16)
#if os(iOS)
.padding(.top, 8)
.padding(.bottom, 10)
#else
.padding(.vertical, 10)
#endif
.background(.bar)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
} else if app.isPreparingPlayback {
VStack(spacing: 0) {
Divider()
HStack(spacing: 12) {
ProgressView()
Text("Wiedergabe wird vorbereitet …").font(.subheadline).foregroundStyle(.secondary)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(.bar)
}
}
}
// MARK: - Platform layouts
#if os(iOS)
@ViewBuilder
private func content(item: LibraryItem) -> some View {
VStack(spacing: 8) {
// Header row: cover, title/author, play button
HStack(spacing: 12) {
cover(item: item)
VStack(alignment: .leading, spacing: 2) {
Text(item.title).font(.subheadline).bold().lineLimit(1)
Text(item.author).font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let err = app.player.errorMessage {
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1)
}
}
Spacer(minLength: 0)
Button { app.togglePlay() } label: {
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
}
scrubber
HStack(spacing: 24) {
Button { app.skip(by: -Double(skipSeconds)) } label: {
Image(systemName: skipBackImage).font(.system(size: 22))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
Button { app.skip(by: Double(skipSeconds)) } label: {
Image(systemName: skipForwardImage).font(.system(size: 22))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
Spacer()
rateMenu
Button {
app.stopPlayback()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 22))
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
.padding(.top, 2)
}
}
#else
@ViewBuilder
private func content(item: LibraryItem) -> some View {
HStack(spacing: 14) {
cover(item: item)
VStack(alignment: .leading, spacing: 2) {
Text(item.title).font(.subheadline).bold().lineLimit(1)
Text(item.author).font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let err = app.player.errorMessage {
Text(err).font(.caption2).foregroundStyle(.red).lineLimit(1)
}
}
.frame(minWidth: 160, idealWidth: 200, maxWidth: 240, alignment: .leading)
transportControls
scrubber
.frame(minWidth: 200)
rateMenu
Spacer(minLength: 0)
statusIndicator
Button {
app.stopPlayback()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Wiedergabe beenden")
}
}
private var transportControls: some View {
HStack(spacing: 14) {
Button { app.skip(by: -Double(skipSeconds)) } label: {
Image(systemName: skipBackImage).font(.system(size: 18))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
Button { app.togglePlay() } label: {
Image(systemName: app.player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 34))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
.keyboardShortcut(.space, modifiers: [])
Button { app.skip(by: Double(skipSeconds)) } label: {
Image(systemName: skipForwardImage).font(.system(size: 18))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
}
}
private var statusIndicator: some View {
HStack(spacing: 4) {
Circle()
.fill(app.network.isOnline ? .green : .orange)
.frame(width: 6, height: 6)
if app.sync.queuedCount > 0 {
Text("\(app.sync.queuedCount)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.help(app.network.isOnline
? "Online Fortschritt wird synchronisiert"
: "Offline \(app.sync.queuedCount) Eintrag/Einträge wartend")
}
#endif
// MARK: - Shared subviews
private func cover(item: LibraryItem) -> some View {
Group {
if let url = app.client.coverURL(itemId: item.id) {
AsyncImage(url: url) { phase in
if let img = phase.image {
img.resizable().aspectRatio(contentMode: .fill)
} else {
Color.gray.opacity(0.3)
}
}
} else {
Color.gray.opacity(0.3)
}
}
#if os(iOS)
.frame(width: 44, height: 44)
#else
.frame(width: 48, height: 48)
#endif
.clipShape(RoundedRectangle(cornerRadius: 6))
}
private var scrubber: 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)
}
}
}
private var rateMenu: some View {
Menu {
ForEach([0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { r in
Button {
app.setRate(Float(r))
} label: {
HStack {
Text(String(format: "%.2g×", r))
if abs(Double(app.player.rate) - r) < 0.01 {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Text(String(format: "%.2g×", Double(app.player.rate)))
.font(.caption.monospacedDigit())
.padding(.horizontal, 10).padding(.vertical, 5)
.overlay(Capsule().stroke(Color.secondary.opacity(0.4)))
}
#if os(macOS)
.menuStyle(.borderlessButton)
.fixedSize()
.help("Geschwindigkeit")
#endif
}
private var skipForwardImage: String {
switch skipSeconds {
case ...10: return "goforward.10"
case 11...15: return "goforward.15"
case 16...30: return "goforward.30"
case 31...45: return "goforward.45"
case 46...60: return "goforward.60"
default: return "goforward.90"
}
}
private var skipBackImage: String {
switch skipSeconds {
case ...10: return "gobackward.10"
case 11...15: return "gobackward.15"
case 16...30: return "gobackward.30"
case 31...45: return "gobackward.45"
case 46...60: return "gobackward.60"
default: return "gobackward.90"
}
}
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)
}
}