Files
omnivore/apple/OmnivoreKit/Sources/App/Views/AudioPlayer/ExpandedAudioPlayer.swift
2023-12-27 10:58:04 +08:00

487 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#if os(iOS)
import CoreData
import Foundation
import Models
import Services
import SwiftUI
import Transmission
import Views
// swiftlint:disable file_length type_body_length
public struct ExpandedAudioPlayer: View {
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@Environment(\.dismiss) private var dismiss
let delete: (_: NSManagedObjectID) -> Void
let archive: (_: NSManagedObjectID) -> Void
let viewArticle: (_: NSManagedObjectID) -> Void
@State var showVoiceSheet = false
@State var tabIndex: Int = 0
@State var showLabelsModal = false
@State var showNotebookView = false
@State var showOperationToast = false
@State var operationStatus: OperationStatus = .none
@State var operationMessage: String?
var playPauseButtonImage: String {
switch audioController.state {
case .playing:
return "pause.circle"
case .paused:
return "play.circle"
case .reachedEnd:
return "gobackward"
default:
return ""
}
}
var playPauseButtonItem: some View {
if audioController.playbackError {
return AnyView(Color.clear)
}
if let itemID = audioController.itemAudioProperties?.itemID, audioController.isLoadingItem(itemID: itemID) {
return AnyView(ProgressView())
} else {
return AnyView(Button(
action: {
switch audioController.state {
case .playing:
audioController.pause()
case .paused:
audioController.unpause()
case .reachedEnd:
audioController.seek(to: 0.0)
audioController.unpause()
default:
break
}
},
label: {
Image(systemName: playPauseButtonImage)
.resizable(resizingMode: Image.ResizingMode.stretch)
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.light))
}
)
.buttonStyle(.plain)
)
}
}
var closeButton: some View {
ZStack {
Circle()
.foregroundColor(Color.appGrayText)
.frame(width: 36, height: 36)
.opacity(0.1)
Image(systemName: "chevron.down")
.font(.appCallout)
.frame(width: 36, height: 36)
}
}
var toolbarItems: some ToolbarContent {
ToolbarItemGroup(placement: .barTrailing) {
Button(
action: { performDelete() },
label: {
Image
.toolbarTrash
.foregroundColor(Color.toolbarItemForeground)
}
).padding(.trailing, 5)
if !(audioController.itemAudioProperties?.isArchived ?? false) {
Button(
action: { performArchive() },
label: {
if audioController.itemAudioProperties?.isArchived ?? false {
Image
.toolbarUnarchive
.foregroundColor(Color.toolbarItemForeground)
} else {
Image
.toolbarArchive
.foregroundColor(Color.toolbarItemForeground)
}
}
).padding(.trailing, 5)
}
// Menu(content: {
// Button(
// action: { performViewArticle() },
// label: {
// Text("View article")
// }
// )
// }, label: {
// Image
// .utilityMenu
// .foregroundColor(ThemeManager.currentTheme.toolbarColor)
// })
}
}
func performViewArticle() {
if let objectID = audioController.itemAudioProperties?.objectID {
viewArticle(objectID)
}
}
func performDelete() {
if let objectID = audioController.itemAudioProperties?.objectID {
delete(objectID)
}
}
func performArchive() {
if let objectID = audioController.itemAudioProperties?.objectID {
archive(objectID)
}
}
struct SpeechCard: View {
let id: Int
@EnvironmentObject var audioController: AudioController
var intervalFormatter: DateComponentsFormatter {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.minute, .second]
formatter.zeroFormattingBehavior = .pad
return formatter
}
var body: some View {
let isCurrent = id == self.audioController.currentAudioIndex
ZStack(alignment: .top) {
Text(intervalFormatter.string(from: self.audioController.offsets?[id] ?? 0.0) ?? "")
.font(Font.system(size: 11, weight: isCurrent ? .medium : .regular))
.foregroundColor(isCurrent ? Color.themeTTSReadingText : Color(hex: "#898989"))
.padding(.leading, 8)
.padding(.top, 2)
.frame(maxWidth: .infinity, alignment: .leading)
if id != self.audioController.currentAudioIndex || self.audioController.isLoading {
Text(self.audioController.textItems?[id] ?? "\(id)")
.font(.appCallout)
.foregroundColor(Color(hex: "#898989"))
.padding(.leading, 48 + 8)
.padding(.trailing, 16)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Group {
Text(audioController.readText)
.font(.appTextToSpeechCurrent)
.foregroundColor(Color.themeTTSReadingText)
+
Text(audioController.unreadText)
.font(.appTextToSpeechCurrent)
.foregroundColor(Color.themeTTSReadingText)
}
.padding(.leading, 48 + 8)
.padding(.trailing, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 15)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isCurrent ? Color.themeHighlightColor : Color.themeDisabledBG)
}
init(id: Int) {
self.id = id
}
}
var audioCards: some View {
ZStack {
let textItems = self.audioController.textItems ?? []
if textItems.count > 0 {
ScrollViewReader { scroller in
List {
ForEach(0 ..< textItems.count, id: \.self) { id in
SpeechCard(id: id)
.tag(id)
.onTapGesture {
audioController.seek(toUtterance: id)
}
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
// Extra bottom padding, so the controls appear to be over the list
Color.themeDisabledBG
.frame(maxWidth: .infinity)
.frame(height: 150)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
.listStyle(.plain)
.onAppear {
if audioController.currentAudioIndex < textItems.count {
withAnimation {
scroller.scrollTo(audioController.currentAudioIndex, anchor: .center)
}
}
}
.onChange(of: audioController.currentAudioIndex, perform: { index in
if index >= textItems.count {
return
}
if self.audioController.state != .reachedEnd {
withAnimation {
scroller.scrollTo(index, anchor: .center)
}
}
})
// .simultaneousGesture(
// DragGesture().onChanged {
// let isScrollDown = $0.translation.height > 0
// print(isScrollDown)
// }
// )
}
.background(Color.themeDisabledBG)
}
if audioController.state == .reachedEnd {
// If we have reached the end display a replay button with an overlay behind
Color.systemBackground.opacity(0.85)
.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: .topLeading
)
Button(
action: {
tabIndex = 0
audioController.unpause()
audioController.seek(to: 0.0)
},
label: {
HStack {
Image(systemName: "gobackward")
.font(.appCallout)
.tint(.appGrayTextContrast)
Text(LocalText.audioPlayerReplay)
}
}
)
.padding(.bottom, 138)
.buttonStyle(RoundedRectButtonStyle())
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
var scrubber: some View {
Group {
ScrubberView(value: $audioController.timeElapsed,
maxValue: $audioController.duration,
onEditingChanged: { scrubStarted in
if scrubStarted {
self.audioController.scrubState = .scrubStarted
} else {
self.audioController.scrubState = .scrubEnded(self.audioController.timeElapsed)
}
})
.padding(.top, 26)
HStack {
Text(audioController.timeElapsedString ?? "0:00")
.font(Font.system(size: 10))
.foregroundColor(Color(hex: "#9D9D9B"))
Spacer()
Text(audioController.durationString ?? "0:00")
.font(Font.system(size: 10))
.foregroundColor(Color(hex: "#9D9D9B"))
}
}
.padding(.leading, 42)
.padding(.trailing, 42)
}
var audioButtons: some View {
HStack(alignment: .center) {
Spacer()
Button(action: { showVoiceSheet = true }, label: {
Image(systemName: "person.wave.2")
.resizable()
.frame(width: 18, height: 18)
})
.buttonStyle(.plain)
.padding(.trailing, 32)
Button(
action: { self.audioController.skipBackwards(seconds: 15) },
label: {
Image(systemName: "gobackward.15")
.resizable()
.font(Font.title.weight(.light))
}
)
.buttonStyle(.plain)
.frame(width: 16, height: 16)
.padding(.trailing, 16)
.foregroundColor(.themeAudioPlayerGray)
playPauseButtonItem
.frame(width: 45, height: 45)
.padding(.trailing, 16)
.foregroundColor(.themeAudioPlayerGray)
Button(
action: { self.audioController.skipForward(seconds: 15) },
label: {
Image(systemName: "goforward.15")
.resizable()
.font(Font.title.weight(.light))
}
)
.buttonStyle(.plain)
.frame(width: 16, height: 16)
.padding(.trailing, 32 - 4) // -4 to account for the menu touch padding
.foregroundColor(.themeAudioPlayerGray)
Menu(content: {
playbackRateButton(rate: 0.8, title: "0.8×", selected: audioController.playbackRate == 0.8)
playbackRateButton(rate: 0.9, title: "0.9×", selected: audioController.playbackRate == 0.9)
playbackRateButton(rate: 1.0, title: "1.0×", selected: audioController.playbackRate == 1.0)
playbackRateButton(rate: 1.1, title: "1.1×", selected: audioController.playbackRate == 1.1)
playbackRateButton(rate: 1.2, title: "1.2×", selected: audioController.playbackRate == 1.2)
playbackRateButton(rate: 1.5, title: "1.5×", selected: audioController.playbackRate == 1.5)
playbackRateButton(rate: 1.7, title: "1.7×", selected: audioController.playbackRate == 1.7)
playbackRateButton(rate: 2.0, title: "2.0×", selected: audioController.playbackRate == 2.0)
playbackRateButton(rate: 2.2, title: "2.2×", selected: audioController.playbackRate == 2.2)
playbackRateButton(rate: 2.5, title: "2.5×", selected: audioController.playbackRate == 2.5)
}, label: {
Text("\(String(format: "%.1f", audioController.playbackRate))×")
.font(.appCaption)
})
.buttonStyle(.plain)
.padding(4)
Spacer()
}
.foregroundColor(Color.themeMediumGray)
.padding(.bottom, 16)
}
func playerContent(_: LinkedItemAudioProperties) -> some View {
ZStack {
WindowLink(level: .alert, transition: .move(edge: .bottom), isPresented: $showOperationToast) {
OperationToast(operationMessage: $operationMessage, showOperationToast: $showOperationToast, operationStatus: $operationStatus)
.offset(y: -90)
} label: {
EmptyView()
}.buttonStyle(.plain)
if audioController.playbackError {
Text("There was an error playing back your audio.").foregroundColor(Color.red).font(.footnote)
}
audioCards
.frame(maxHeight: .infinity)
VStack {
Spacer()
VStack {
scrubber
audioButtons
}
.padding(.bottom, 8)
.cornerRadius(8)
.padding(.bottom, -8)
.frame(maxWidth: .infinity, maxHeight: 138)
.background(Color.themeSolidBackground.ignoresSafeArea())
}
}
.onAppear {
self.tabIndex = audioController.currentAudioIndex
}
.onChange(of: audioController.state, perform: { state in
// Reset the tabIndex when we load a new audio item
if state == .loading {
tabIndex = 0
}
})
.sheet(isPresented: $showVoiceSheet) {
NavigationView {
TextToSpeechVoiceSelectionView(forLanguage: audioController.currentVoiceLanguage, showLanguageChanger: true)
.navigationBarTitle("Voice")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing: Button(action: { self.showVoiceSheet = false }, label: {
Text("Done").bold()
}))
}
}
}
func playbackRateButton(rate: Double, title: String, selected: Bool) -> some View {
Button(action: {
audioController.playbackRate = rate
}, label: {
HStack {
Text(title)
Spacer()
if selected {
Image(systemName: "checkmark")
}
}
.contentShape(Rectangle())
})
.buttonStyle(PlainButtonStyle())
}
@State var queryString: String = ""
public var body: some View {
NavigationView {
innerBody
.background(Color.themeDisabledBG)
.navigationTitle("")
.navigationBarItems(leading: Button(action: { dismiss() }, label: { closeButton }))
.navigationBarTitleDisplayMode(NavigationBarItem.TitleDisplayMode.inline)
.toolbar {
toolbarItems
}
}
}
public var innerBody: some View {
if let itemAudioProperties = self.audioController.itemAudioProperties {
return AnyView(
playerContent(itemAudioProperties)
.tint(.appGrayTextContrast)
)
} else {
return AnyView(EmptyView())
}
}
var scrubbing: Bool {
switch audioController.scrubState {
case .scrubStarted:
return true
default:
return false
}
}
}
#endif