216 lines
7.2 KiB
Swift
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()
|
|
}
|
|
}
|