Files
ABS-Client/Audiobookshelf swift/PlayerView.swift
2026-05-14 19:24:52 +02:00

216 lines
7.2 KiB
Swift

import SwiftUI
import AVFoundation
struct PlayerView: View {
let book: AudiobookItem
@EnvironmentObject var authManager: AuthManager
@StateObject private var vm = PlayerViewModel()
var body: some View {
VStack(spacing: 24) {
// Cover
if let coverURL = book.coverURL, let url = URL(string: coverURL) {
AsyncImage(url: url) { image in
image.resizable().aspectRatio(contentMode: .fit)
} placeholder: {
Rectangle().fill(Color.gray.opacity(0.3))
.overlay(Image(systemName: "book.fill").font(.largeTitle).foregroundColor(.gray))
}
.frame(maxWidth: 280, maxHeight: 280)
.cornerRadius(12)
.shadow(radius: 8)
} else {
Rectangle().fill(Color.gray.opacity(0.3))
.frame(width: 280, height: 280)
.cornerRadius(12)
.overlay(Image(systemName: "book.fill").font(.largeTitle).foregroundColor(.gray))
}
// Titel & Autor
VStack(spacing: 4) {
Text(book.title)
.font(.title2).bold()
.multilineTextAlignment(.center)
Text(book.author)
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.horizontal)
// Fortschrittsbalken
VStack(spacing: 4) {
Slider(value: $vm.currentTime, in: 0...max(vm.duration, 1)) { editing in
if !editing {
vm.seek(to: vm.currentTime)
}
}
.padding(.horizontal)
HStack {
Text(formatTime(vm.currentTime))
Spacer()
Text(formatTime(vm.duration))
}
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal)
}
// Steuerung
HStack(spacing: 40) {
Button(action: { vm.skip(-30) }) {
Image(systemName: "gobackward.30")
.font(.title)
}
Button(action: { vm.togglePlayPause() }) {
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 64))
}
Button(action: { vm.skip(30) }) {
Image(systemName: "goforward.30")
.font(.title)
}
}
.foregroundColor(.accentColor)
// Wiedergabegeschwindigkeit
HStack {
Text("Geschwindigkeit:")
.font(.caption)
Picker("", selection: $vm.playbackRate) {
Text("0.75x").tag(Float(0.75))
Text("1.0x").tag(Float(1.0))
Text("1.25x").tag(Float(1.25))
Text("1.5x").tag(Float(1.5))
Text("2.0x").tag(Float(2.0))
}
.pickerStyle(.segmented)
}
.padding(.horizontal)
Spacer()
}
.padding(.top)
.navigationTitle("Player")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
vm.setup(book: book, authManager: authManager)
}
.onDisappear {
vm.saveProgress(authManager: authManager)
}
}
func formatTime(_ seconds: Double) -> String {
let s = Int(seconds)
return String(format: "%d:%02d:%02d", s/3600, (s%3600)/60, s%60)
}
}
// MARK: - PlayerViewModel
class PlayerViewModel: ObservableObject {
@Published var isPlaying = false
@Published var currentTime: Double = 0
@Published var duration: Double = 0
@Published var playbackRate: Float = 1.0 {
didSet { player?.rate = isPlaying ? playbackRate : 0 }
}
private var player: AVPlayer?
private var playerItem: AVPlayerItem?
private var timeObserver: Any?
private var book: AudiobookItem?
private var authManager: AuthManager?
func setup(book: AudiobookItem, authManager: AuthManager) {
self.book = book
self.authManager = authManager
guard let serverURL = authManager.serverURL,
let token = authManager.token,
let firstFile = book.mediaFiles.first else { return }
let urlString = "\(serverURL)/api/items/\(book.id)/file/\(firstFile.ino)"
guard let url = URL(string: urlString) else { return }
var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": ["Authorization": "Bearer \(token)"]])
playerItem = AVPlayerItem(asset: asset)
player = AVPlayer(playerItem: playerItem)
// Duration
Task {
if let dur = try? await asset.load(.duration) {
await MainActor.run {
self.duration = dur.seconds
}
}
}
// Fortschritt laden
Task {
if let progress = try? await ABSService.shared.fetchProgress(
serverURL: serverURL, token: token, itemId: book.id) {
await MainActor.run {
self.currentTime = progress.currentTime
self.player?.seek(to: CMTime(seconds: progress.currentTime, preferredTimescale: 1000))
}
}
}
// Time Observer
let interval = CMTime(seconds: 1, preferredTimescale: 1000)
timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
self?.currentTime = time.seconds
}
}
func togglePlayPause() {
guard let player else { return }
if isPlaying {
player.pause()
} else {
player.rate = playbackRate
}
isPlaying.toggle()
}
func skip(_ seconds: Double) {
guard let player else { return }
let newTime = max(0, min(currentTime + seconds, duration))
player.seek(to: CMTime(seconds: newTime, preferredTimescale: 1000))
currentTime = newTime
}
func seek(to time: Double) {
player?.seek(to: CMTime(seconds: time, preferredTimescale: 1000))
}
func saveProgress(authManager: AuthManager) {
guard let book,
let serverURL = authManager.serverURL,
let token = authManager.token else { return }
Task {
await ABSService.shared.updateProgress(
serverURL: serverURL,
token: token,
itemId: book.id,
currentTime: currentTime,
duration: duration
)
}
}
deinit {
if let timeObserver {
player?.removeTimeObserver(timeObserver)
}
player?.pause()
}
}