Grundcode AudiobookshelfClient
This commit is contained in:
215
Audiobookshelf swift/PlayerView.swift
Normal file
215
Audiobookshelf swift/PlayerView.swift
Normal file
@@ -0,0 +1,215 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user