ReadKeep/readeck/UI/SpeechPlayer/SpeechPlayerView.swift
Ilyas Hallak 09f1ddea58 Add text-to-speech functionality
- Add TTSManager and SpeechQueue utilities
- Create AddTextToSpeechQueueUseCase and ReadBookmarkUseCase
- Add SpeechPlayer UI components (GlobalPlayerContainerView, SpeechPlayerView, SpeechPlayerViewModel)
- Update BookmarkDetailView and BookmarkDetailViewModel for TTS integration
- Add audio background mode to Info.plist
- Update PhoneTabView for TTS controls
- Add StringExtensions for text processing
- Add StringExtensionsTests for testing
- Update Localizable.xcstrings with new strings
- Add VS Code settings
2025-07-09 22:31:17 +02:00

187 lines
6.1 KiB
Swift

import SwiftUI
struct SpeechPlayerView: View {
@State var viewModel = SpeechPlayerViewModel()
@State private var isExpanded = false
@State private var dragOffset: CGFloat = 0
@State private var speechQueue = SpeechQueue.shared
@State private var ttsManager = TTSManager.shared
private let minHeight: CGFloat = 60
private let maxHeight: CGFloat = 300
var body: some View {
VStack(spacing: 0) {
if isExpanded {
expandedView
} else {
collapsedView
}
}
.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
}
}
)
}
private var collapsedView: some View {
HStack(spacing: 16) {
// Play/Pause Button
Button(action: {
if ttsManager.isCurrentlySpeaking() {
viewModel.pause()
} else {
viewModel.resume()
}
}) {
Image(systemName: ttsManager.isCurrentlySpeaking() ? "pause.fill" : "play.fill")
.font(.title2)
.foregroundColor(.accentColor)
}
// Current Text
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.currentText.isEmpty ? "Keine Wiedergabe" : viewModel.currentText)
.font(.subheadline)
.fontWeight(.medium)
.lineLimit(1)
.truncationMode(.tail)
if speechQueue.queueCount > 0 {
Text("\(speechQueue.queueCount) Artikel in Queue")
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
// Stop Button
Button(action: {
viewModel.stop()
}) {
Image(systemName: "stop.fill")
.font(.title3)
.foregroundColor(.secondary)
}
// Expand Button
Button(action: {
withAnimation(.spring()) {
isExpanded.toggle()
}
}) {
Image(systemName: "chevron.up")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
private var expandedView: some View {
VStack(spacing: 16) {
// Header
HStack {
Text("Vorlese-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)
// Controls
HStack(spacing: 24) {
Button(action: {
if ttsManager.isCurrentlySpeaking() {
viewModel.pause()
} else {
viewModel.resume()
}
}) {
Image(systemName: ttsManager.isCurrentlySpeaking() ? "pause.fill" : "play.fill")
.font(.title)
.foregroundColor(.accentColor)
}
Button(action: {
viewModel.stop()
}) {
Image(systemName: "stop.fill")
.font(.title2)
.foregroundColor(.secondary)
}
}
// Queue List
if speechQueue.queueCount == 0 {
Text("Keine Artikel in der Queue")
.font(.subheadline)
.foregroundColor(.secondary)
.padding()
} else {
ScrollView {
LazyVStack(spacing: 8) {
ForEach(Array(speechQueue.queueItems.enumerated()), id: \.offset) { index, item in
HStack {
Text("\(index + 1).")
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 20, alignment: .leading)
Text(item)
.font(.subheadline)
.lineLimit(2)
.truncationMode(.tail)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
.padding(.horizontal, 16)
}
}
Spacer()
}
}
}
#Preview {
SpeechPlayerView()
}