- Implemented a toggle for the 'Read Aloud' (TTS) feature in the general settings. - Refactored AppSettings and PlayerUIState to support TTS enable/disable. - Updated BookmarkDetailView, PadSidebarView, PhoneTabView, and GlobalPlayerContainerView to respect the TTS setting. - Added new RButton component for consistent button styling. - Improved LabelsView to support tag selection on iPad and iPhone. - Updated SettingsGeneralView and SettingsGeneralViewModel for new TTS logic and removed unused app info code. - Added app info section to SettingsContainerView. - Updated SettingsServerView to use English labels and messages. - Refactored SpeechPlayerViewModel to only initialize TTS when enabled. - Updated Core Data model to include enableTTS in SettingEntity. - Removed obsolete files (PersistenceController.swift, old PlayerUIState). - Various bugfixes, code cleanups, and UI improvements.
309 lines
11 KiB
Swift
309 lines
11 KiB
Swift
import SwiftUI
|
|
|
|
struct SpeechPlayerView: View {
|
|
@State var viewModel = SpeechPlayerViewModel()
|
|
@State private var isExpanded = false
|
|
@State private var dragOffset: CGFloat = 0
|
|
var onClose: (() -> Void)? = nil
|
|
|
|
private let minHeight: CGFloat = 60
|
|
private let maxHeight: CGFloat = UIScreen.main.bounds.height / 2
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
if isExpanded {
|
|
ExpandedPlayerView(viewModel: viewModel, isExpanded: $isExpanded, onClose: onClose)
|
|
} else {
|
|
CollapsedPlayerBar(viewModel: viewModel, isExpanded: $isExpanded)
|
|
}
|
|
}
|
|
.frame(height: isExpanded ? maxHeight : minHeight)
|
|
.background(.ultraThinMaterial)
|
|
.cornerRadius(12)
|
|
.shadow(radius: 8, x: 0, y: -2)
|
|
.offset(y: dragOffset)
|
|
.gesture(
|
|
DragGesture()
|
|
.onChanged { value in
|
|
dragOffset = value.translation.height
|
|
}
|
|
.onEnded { value in
|
|
withAnimation(.spring()) {
|
|
if value.translation.height < -50 && !isExpanded {
|
|
isExpanded = true
|
|
} else if value.translation.height > 50 && isExpanded {
|
|
isExpanded = false
|
|
}
|
|
dragOffset = 0
|
|
}
|
|
}
|
|
)
|
|
.onAppear() {
|
|
Task {
|
|
await viewModel.setup()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct CollapsedPlayerBar: View {
|
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
|
@Binding var isExpanded: Bool
|
|
|
|
var body: some View {
|
|
HStack(spacing: 16) {
|
|
Button(action: {
|
|
if viewModel.isSpeaking {
|
|
viewModel.pause()
|
|
} else {
|
|
viewModel.resume()
|
|
}
|
|
}) {
|
|
Image(systemName: viewModel.isSpeaking ? "pause.fill" : "play.fill")
|
|
.font(.title2)
|
|
.foregroundColor(.accentColor)
|
|
}
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(viewModel.currentText.isEmpty ? "No playback" : viewModel.currentText)
|
|
.font(.subheadline)
|
|
.fontWeight(.medium)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
if viewModel.articleProgress > 0 && viewModel.articleProgress < 1 {
|
|
ProgressView(value: viewModel.articleProgress)
|
|
.progressViewStyle(LinearProgressViewStyle(tint: .accentColor))
|
|
.scaleEffect(y: 0.8)
|
|
}
|
|
if viewModel.queueCount > 0 {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "text.line.first.and.arrowtriangle.forward")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
Text("\(viewModel.currentUtteranceIndex + 1)/\(viewModel.queueCount)")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}.onTapGesture {
|
|
withAnimation(.spring()) { isExpanded.toggle() }
|
|
}
|
|
Spacer()
|
|
Button(action: { viewModel.stop() }) {
|
|
Image(systemName: "stop.fill")
|
|
.font(.title3)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Button(action: { withAnimation(.spring()) { isExpanded.toggle() } }) {
|
|
Image(systemName: "chevron.up")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
}
|
|
|
|
private struct ExpandedPlayerView: View {
|
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
|
@Binding var isExpanded: Bool
|
|
var onClose: (() -> Void)? = nil
|
|
|
|
var body: some View {
|
|
VStack(spacing: 16) {
|
|
// Header
|
|
HStack {
|
|
Button(action: { onClose?() }) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.title2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
Text("Read-aloud Queue")
|
|
.font(.headline)
|
|
.fontWeight(.semibold)
|
|
Spacer()
|
|
Button(action: { withAnimation(.spring()) { isExpanded = false } }) {
|
|
Image(systemName: "chevron.down")
|
|
.font(.title3)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
// progress bar for current article
|
|
if viewModel.articleProgress > 0 && viewModel.articleProgress < 1 {
|
|
VStack(spacing: 4) {
|
|
ProgressView(value: viewModel.articleProgress)
|
|
.progressViewStyle(LinearProgressViewStyle(tint: .accentColor))
|
|
HStack {
|
|
Text("Progress: \(Int(viewModel.articleProgress * 100))%")
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
Spacer()
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
PlayerControls(viewModel: viewModel)
|
|
PlayerVolume(viewModel: viewModel)
|
|
|
|
if viewModel.queueCount > 0 {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "text.line.first.and.arrowtriangle.forward")
|
|
.foregroundColor(.accentColor)
|
|
Text("Reading \(viewModel.currentUtteranceIndex + 1)/\(viewModel.queueCount): ")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Text(viewModel.queueItems[safe: viewModel.currentUtteranceIndex]?.title ?? "")
|
|
.font(.caption)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
PlayerQueueList(viewModel: viewModel)
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PlayerControls: View {
|
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
|
let rates: [Float] = [0.25, 0.5, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]
|
|
var body: some View {
|
|
ZStack {
|
|
HStack {
|
|
Spacer()
|
|
HStack(spacing: 24) {
|
|
Button(action: {
|
|
if viewModel.isSpeaking {
|
|
viewModel.pause()
|
|
} else {
|
|
viewModel.resume()
|
|
}
|
|
}) {
|
|
Image(systemName: viewModel.isSpeaking ? "pause.fill" : "play.fill")
|
|
.font(.title)
|
|
.foregroundColor(.accentColor)
|
|
}
|
|
Button(action: { viewModel.stop() }) {
|
|
Image(systemName: "stop.fill")
|
|
.font(.title2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
HStack {
|
|
Spacer()
|
|
Picker("Speed", selection: Binding(
|
|
get: { viewModel.rate },
|
|
set: { viewModel.setRate($0) }
|
|
)) {
|
|
ForEach(rates, id: \ .self) { value in
|
|
Text(String(format: "%.2fx", value)).tag(Float(value))
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.frame(maxWidth: 120)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
}
|
|
|
|
private struct PlayerVolume: View {
|
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Image(systemName: "speaker.wave.2.fill")
|
|
.foregroundColor(.accentColor)
|
|
Slider(value: Binding(
|
|
get: { viewModel.volume },
|
|
set: { viewModel.setVolume($0) }
|
|
), in: 0...1, step: 0.01)
|
|
Text(String(format: "%.0f%%", viewModel.volume * 100))
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
.frame(width: 40, alignment: .trailing)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
}
|
|
|
|
private struct PlayerRate: View {
|
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
|
let rates: [Float] = [0.25, 0.5, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Image(systemName: "speedometer")
|
|
.foregroundColor(.accentColor)
|
|
Picker("Speed", selection: Binding(
|
|
get: { viewModel.rate },
|
|
set: { viewModel.setRate($0) }
|
|
)) {
|
|
ForEach(rates, id: \ .self) { value in
|
|
Text(String(format: "%.2fx", value)).tag(Float(value))
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.frame(maxWidth: 120)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
}
|
|
|
|
private struct PlayerQueueList: View {
|
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
|
var body: some View {
|
|
if viewModel.queueCount == 0 {
|
|
Text("No articles in the queue")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.padding()
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: 8) {
|
|
ForEach(Array(viewModel.queueItems.enumerated()), id: \.offset) { index, item in
|
|
HStack {
|
|
Text("\(index + 1).")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.frame(width: 20, alignment: .leading)
|
|
Text(item.title)
|
|
.font(.subheadline)
|
|
.lineLimit(2)
|
|
.truncationMode(.tail)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Array safe access helper
|
|
fileprivate extension Array {
|
|
subscript(safe index: Int) -> Element? {
|
|
indices.contains(index) ? self[index] : nil
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
SpeechPlayerView()
|
|
}
|