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
This commit is contained in:
parent
3e6db364b5
commit
09f1ddea58
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"editor.fontSize": 14
|
||||||
|
}
|
||||||
@ -3,12 +3,31 @@
|
|||||||
"strings" : {
|
"strings" : {
|
||||||
"" : {
|
"" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"%@ (%lld)" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$@ (%2$lld)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"%lld" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"%lld Artikel in Queue" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"%lld min" : {
|
"%lld min" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"%lld Minuten" : {
|
"%lld Minuten" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"%lld." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"12 min • Today • example.com" : {
|
"12 min • Today • example.com" : {
|
||||||
|
|
||||||
@ -44,6 +63,9 @@
|
|||||||
},
|
},
|
||||||
"Artikel automatisch als gelesen markieren" : {
|
"Artikel automatisch als gelesen markieren" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Artikel vorlesen" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Automatischer Sync" : {
|
"Automatischer Sync" : {
|
||||||
|
|
||||||
@ -107,6 +129,9 @@
|
|||||||
},
|
},
|
||||||
"Fehler" : {
|
"Fehler" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Fehler: %@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Fertig" : {
|
"Fertig" : {
|
||||||
|
|
||||||
@ -137,6 +162,9 @@
|
|||||||
},
|
},
|
||||||
"Item at %@" : {
|
"Item at %@" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Keine Artikel in der Queue" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Keine Bookmarks" : {
|
"Keine Bookmarks" : {
|
||||||
|
|
||||||
@ -266,6 +294,9 @@
|
|||||||
},
|
},
|
||||||
"Version %@" : {
|
"Version %@" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Vorlese-Queue" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Vorschau" : {
|
"Vorschau" : {
|
||||||
|
|
||||||
|
|||||||
19
readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift
Normal file
19
readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class AddTextToSpeechQueueUseCase {
|
||||||
|
private let speechQueue: SpeechQueue
|
||||||
|
|
||||||
|
init(speechQueue: SpeechQueue = .shared) {
|
||||||
|
self.speechQueue = speechQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(bookmarkDetail: BookmarkDetail) {
|
||||||
|
var text = bookmarkDetail.title + "\n"
|
||||||
|
if let content = bookmarkDetail.content {
|
||||||
|
text += content.stripHTML
|
||||||
|
} else {
|
||||||
|
text += bookmarkDetail.description.stripHTML
|
||||||
|
}
|
||||||
|
speechQueue.enqueue(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
readeck/Domain/UseCase/ReadBookmarkUseCase.swift
Normal file
13
readeck/Domain/UseCase/ReadBookmarkUseCase.swift
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class ReadBookmarkUseCase {
|
||||||
|
private let addToSpeechQueue: AddTextToSpeechQueueUseCase
|
||||||
|
|
||||||
|
init(addToSpeechQueue: AddTextToSpeechQueueUseCase = AddTextToSpeechQueueUseCase()) {
|
||||||
|
self.addToSpeechQueue = addToSpeechQueue
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(bookmarkDetail: BookmarkDetail) {
|
||||||
|
addToSpeechQueue.execute(bookmarkDetail: bookmarkDetail)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,9 +16,13 @@
|
|||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIColorName</key>
|
<key>UIColorName</key>
|
||||||
<string>green</string>
|
<string>green2</string>
|
||||||
<key>UIImageName</key>
|
<key>UIImageName</key>
|
||||||
<string>readeck</string>
|
<string>readeck</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -225,6 +225,16 @@ struct BookmarkDetailView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metaRow(icon: "speaker.wave.2") {
|
||||||
|
Button(action: {
|
||||||
|
viewModel.addBookmarkToSpeechQueue()
|
||||||
|
}) {
|
||||||
|
Text("Artikel vorlesen")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ class BookmarkDetailViewModel {
|
|||||||
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
|
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
|
||||||
private let loadSettingsUseCase: LoadSettingsUseCase
|
private let loadSettingsUseCase: LoadSettingsUseCase
|
||||||
private let updateBookmarkUseCase: UpdateBookmarkUseCase
|
private let updateBookmarkUseCase: UpdateBookmarkUseCase
|
||||||
|
private let addTextToSpeechQueueUseCase: AddTextToSpeechQueueUseCase
|
||||||
|
|
||||||
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
|
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
|
||||||
var articleContent: String = ""
|
var articleContent: String = ""
|
||||||
@ -22,6 +23,7 @@ class BookmarkDetailViewModel {
|
|||||||
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
||||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||||
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
|
||||||
|
self.addTextToSpeechQueueUseCase = factory.makeAddTextToSpeechQueueUseCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -78,4 +80,9 @@ class BookmarkDetailViewModel {
|
|||||||
func refreshBookmarkDetail(id: String) async {
|
func refreshBookmarkDetail(id: String) async {
|
||||||
await loadBookmarkDetail(id: id)
|
await loadBookmarkDetail(id: id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addBookmarkToSpeechQueue() {
|
||||||
|
bookmarkDetail.content = articleContent
|
||||||
|
addTextToSpeechQueueUseCase.execute(bookmarkDetail: bookmarkDetail)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,42 +15,49 @@ struct PhoneTabView: View {
|
|||||||
@State private var selectedTabIndex: Int = 0
|
@State private var selectedTabIndex: Int = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTabIndex) {
|
GlobalPlayerContainerView {
|
||||||
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
TabView(selection: $selectedTabIndex) {
|
||||||
NavigationStack {
|
ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
|
||||||
tabView(for: tab)
|
NavigationStack {
|
||||||
}
|
|
||||||
.tabItem {
|
|
||||||
Label(tab.label, systemImage: tab.systemImage)
|
|
||||||
}
|
|
||||||
.tag(idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationStack {
|
|
||||||
List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
|
|
||||||
NavigationLink(tag: tab, selection: $selectedMoreTab) {
|
|
||||||
tabView(for: tab)
|
tabView(for: tab)
|
||||||
.navigationTitle(tab.label)
|
}
|
||||||
} label: {
|
.tabItem {
|
||||||
Label(tab.label, systemImage: tab.systemImage)
|
Label(tab.label, systemImage: tab.systemImage)
|
||||||
}
|
}
|
||||||
.listRowBackground(Color(R.color.bookmark_list_bg))
|
.tag(idx)
|
||||||
}
|
}
|
||||||
.navigationTitle("Mehr")
|
|
||||||
.scrollContentBackground(.hidden)
|
NavigationStack {
|
||||||
.background(Color(R.color.bookmark_list_bg))
|
if let selectedTab = selectedMoreTab {
|
||||||
}
|
tabView(for: selectedTab)
|
||||||
.tabItem {
|
.navigationTitle(selectedTab.label)
|
||||||
Label("Mehr", systemImage: "ellipsis")
|
} else {
|
||||||
}
|
List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
|
||||||
.tag(mainTabs.count)
|
NavigationLink {
|
||||||
.onAppear {
|
tabView(for: tab)
|
||||||
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
.navigationTitle(tab.label)
|
||||||
selectedMoreTab = nil
|
} label: {
|
||||||
|
Label(tab.label, systemImage: tab.systemImage)
|
||||||
|
}
|
||||||
|
.listRowBackground(Color(R.color.bookmark_list_bg))
|
||||||
|
}
|
||||||
|
.navigationTitle("Mehr")
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Color(R.color.bookmark_list_bg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Mehr", systemImage: "ellipsis")
|
||||||
|
}
|
||||||
|
.tag(mainTabs.count)
|
||||||
|
.onAppear {
|
||||||
|
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
||||||
|
selectedMoreTab = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accentColor(.accentColor)
|
||||||
}
|
}
|
||||||
.accentColor(.accentColor)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@ -75,7 +82,7 @@ struct PhoneTabView: View {
|
|||||||
case .pictures:
|
case .pictures:
|
||||||
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
|
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
|
||||||
case .tags:
|
case .tags:
|
||||||
Text("Tags")
|
LabelsView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift
Normal file
39
readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GlobalPlayerContainerView<Content: View>: View {
|
||||||
|
let content: Content
|
||||||
|
@State private var speechQueue = SpeechQueue.shared
|
||||||
|
|
||||||
|
init(@ViewBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
content
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
|
if speechQueue.hasItems {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
SpeechPlayerView()
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(.clear)
|
||||||
|
.frame(height: 49)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.spring(), value: speechQueue.hasItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
GlobalPlayerContainerView {
|
||||||
|
Text("Main Content")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
}
|
||||||
|
}
|
||||||
186
readeck/UI/SpeechPlayer/SpeechPlayerView.swift
Normal file
186
readeck/UI/SpeechPlayer/SpeechPlayerView.swift
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
24
readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift
Normal file
24
readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class SpeechPlayerViewModel {
|
||||||
|
var isSpeaking: Bool {
|
||||||
|
return TTSManager.shared.isCurrentlySpeaking()
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentText: String {
|
||||||
|
return SpeechQueue.shared.currentText
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
TTSManager.shared.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume() {
|
||||||
|
TTSManager.shared.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
SpeechQueue.shared.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
74
readeck/Utils/SpeechQueue.swift
Normal file
74
readeck/Utils/SpeechQueue.swift
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
class SpeechQueue {
|
||||||
|
private var queue: [String] = []
|
||||||
|
private var isProcessing = false
|
||||||
|
private let ttsManager: TTSManager
|
||||||
|
private let language: String
|
||||||
|
|
||||||
|
static let shared = SpeechQueue()
|
||||||
|
|
||||||
|
var hasItems: Bool {
|
||||||
|
return !queue.isEmpty || ttsManager.isCurrentlySpeaking()
|
||||||
|
}
|
||||||
|
|
||||||
|
var queueCount: Int {
|
||||||
|
return queue.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentItem: String? {
|
||||||
|
return queue.first
|
||||||
|
}
|
||||||
|
|
||||||
|
var queueItems: [String] {
|
||||||
|
return queue
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentText: String {
|
||||||
|
return queue.first ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private init(ttsManager: TTSManager = .shared, language: String = "de-DE") {
|
||||||
|
self.ttsManager = ttsManager
|
||||||
|
self.language = language
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueue(_ text: String) {
|
||||||
|
queue.append(text)
|
||||||
|
processQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueue(contentsOf texts: [String]) {
|
||||||
|
queue.append(contentsOf: texts)
|
||||||
|
processQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
queue.removeAll()
|
||||||
|
ttsManager.stop()
|
||||||
|
isProcessing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processQueue() {
|
||||||
|
guard !isProcessing, !queue.isEmpty else { return }
|
||||||
|
isProcessing = true
|
||||||
|
let next = queue.removeFirst()
|
||||||
|
ttsManager.speak(text: next, language: language)
|
||||||
|
// Delegate/Notification für didFinish kann hier angebunden werden
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||||
|
self?.waitForSpeechToFinish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForSpeechToFinish() {
|
||||||
|
if ttsManager.isCurrentlySpeaking() {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
||||||
|
self?.waitForSpeechToFinish()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.isProcessing = false
|
||||||
|
self.processQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
readeck/Utils/StringExtensions.swift
Normal file
29
readeck/Utils/StringExtensions.swift
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var stripHTML: String {
|
||||||
|
// Entfernt HTML-Tags und decodiert HTML-Entities
|
||||||
|
let attributedString = try? NSAttributedString(
|
||||||
|
data: Data(utf8),
|
||||||
|
options: [
|
||||||
|
.documentType: NSAttributedString.DocumentType.html,
|
||||||
|
.characterEncoding: String.Encoding.utf8.rawValue
|
||||||
|
],
|
||||||
|
documentAttributes: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
return attributedString?.string ?? self
|
||||||
|
}
|
||||||
|
|
||||||
|
var stripHTMLSimple: String {
|
||||||
|
// Einfache Regex-basierte HTML-Entfernung
|
||||||
|
return self.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil)
|
||||||
|
.replacingOccurrences(of: " ", with: " ")
|
||||||
|
.replacingOccurrences(of: "&", with: "&")
|
||||||
|
.replacingOccurrences(of: "<", with: "<")
|
||||||
|
.replacingOccurrences(of: ">", with: ">")
|
||||||
|
.replacingOccurrences(of: """, with: "\"")
|
||||||
|
.replacingOccurrences(of: "'", with: "'")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
}
|
||||||
165
readeck/Utils/TTSManager.swift
Normal file
165
readeck/Utils/TTSManager.swift
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
class TTSManager: NSObject, AVSpeechSynthesizerDelegate {
|
||||||
|
static let shared = TTSManager()
|
||||||
|
private let synthesizer = AVSpeechSynthesizer()
|
||||||
|
private var isSpeaking = false
|
||||||
|
|
||||||
|
override private init() {
|
||||||
|
super.init()
|
||||||
|
synthesizer.delegate = self
|
||||||
|
configureAudioSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureAudioSession() {
|
||||||
|
do {
|
||||||
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
|
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
|
||||||
|
try audioSession.setActive(true)
|
||||||
|
|
||||||
|
// Background-Audio aktivieren
|
||||||
|
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
|
||||||
|
|
||||||
|
// Notification für App-Lifecycle
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleAppDidEnterBackground),
|
||||||
|
name: UIApplication.didEnterBackgroundNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleAppWillEnterForeground),
|
||||||
|
name: UIApplication.willEnterForegroundNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
print("Fehler beim Konfigurieren der Audio-Session: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func speak(text: String, language: String = "de-DE") {
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
if synthesizer.isSpeaking {
|
||||||
|
synthesizer.stopSpeaking(at: .immediate)
|
||||||
|
}
|
||||||
|
let utterance = AVSpeechUtterance(string: text)
|
||||||
|
|
||||||
|
// Versuche eine hochwertige Stimme zu finden
|
||||||
|
if let enhancedVoice = findEnhancedVoice(for: language) {
|
||||||
|
utterance.voice = enhancedVoice
|
||||||
|
} else {
|
||||||
|
utterance.voice = AVSpeechSynthesisVoice(language: language)
|
||||||
|
}
|
||||||
|
|
||||||
|
utterance.rate = 0.5
|
||||||
|
utterance.pitchMultiplier = 1.0
|
||||||
|
utterance.volume = 1.0
|
||||||
|
|
||||||
|
synthesizer.speak(utterance)
|
||||||
|
isSpeaking = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findEnhancedVoice(for language: String) -> AVSpeechSynthesisVoice? {
|
||||||
|
let availableVoices = AVSpeechSynthesisVoice.speechVoices()
|
||||||
|
|
||||||
|
// Bevorzugte Stimmen für alle Sprachen
|
||||||
|
let preferredVoiceNames = [
|
||||||
|
"Anna", // Deutsche Premium-Stimme
|
||||||
|
"Helena", // Deutsche Premium-Stimme
|
||||||
|
"Siri", // Siri-Stimme (falls verfügbar)
|
||||||
|
"Enhanced", // Enhanced-Stimmen
|
||||||
|
"Karen", // Englische Premium-Stimme
|
||||||
|
"Daniel", // Englische Premium-Stimme
|
||||||
|
"Marie", // Französische Premium-Stimme
|
||||||
|
"Paolo", // Italienische Premium-Stimme
|
||||||
|
"Carmen", // Spanische Premium-Stimme
|
||||||
|
"Yuki" // Japanische Premium-Stimme
|
||||||
|
]
|
||||||
|
|
||||||
|
// Zuerst nach bevorzugten Stimmen für die spezifische Sprache suchen
|
||||||
|
for voiceName in preferredVoiceNames {
|
||||||
|
if let voice = availableVoices.first(where: {
|
||||||
|
$0.language == language &&
|
||||||
|
$0.name.contains(voiceName)
|
||||||
|
}) {
|
||||||
|
return voice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Erste verfügbare Stimme für die Sprache
|
||||||
|
return availableVoices.first(where: { $0.language == language })
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
synthesizer.pauseSpeaking(at: .word)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume() {
|
||||||
|
synthesizer.continueSpeaking()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
synthesizer.stopSpeaking(at: .immediate)
|
||||||
|
isSpeaking = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||||
|
isSpeaking = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||||
|
isSpeaking = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCurrentlySpeaking() -> Bool {
|
||||||
|
return synthesizer.isSpeaking
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleAppDidEnterBackground() {
|
||||||
|
// App geht in Hintergrund - Audio-Session beibehalten
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
} catch {
|
||||||
|
print("Fehler beim Aktivieren der Audio-Session im Hintergrund: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleAppWillEnterForeground() {
|
||||||
|
// App kommt in Vordergrund - Audio-Session erneuern
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
} catch {
|
||||||
|
print("Fehler beim Aktivieren der Audio-Session im Vordergrund: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug-Methode: Zeigt alle verfügbaren Stimmen für eine Sprache
|
||||||
|
func printAvailableVoices(for language: String = "de-DE") {
|
||||||
|
let voices = AVSpeechSynthesisVoice.speechVoices()
|
||||||
|
let filteredVoices = voices.filter { $0.language.starts(with: language.prefix(2)) }
|
||||||
|
|
||||||
|
print("Verfügbare Stimmen für \(language):")
|
||||||
|
for voice in filteredVoices {
|
||||||
|
print("- \(voice.name) (\(voice.language)) - Qualität: \(voice.quality.rawValue)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug-Methode: Zeigt alle verfügbaren Sprachen
|
||||||
|
func printAllAvailableLanguages() {
|
||||||
|
let voices = AVSpeechSynthesisVoice.speechVoices()
|
||||||
|
let languages = Set(voices.map { $0.language })
|
||||||
|
|
||||||
|
print("Verfügbare Sprachen:")
|
||||||
|
for language in languages.sorted() {
|
||||||
|
print("- \(language)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
158
readeckTests/StringExtensionsTests.swift
Normal file
158
readeckTests/StringExtensionsTests.swift
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import readeck
|
||||||
|
|
||||||
|
final class StringExtensionsTests: XCTestCase {
|
||||||
|
|
||||||
|
// MARK: - stripHTML Tests
|
||||||
|
|
||||||
|
func testStripHTML_SimpleTags() {
|
||||||
|
let html = "<p>Dies ist ein <strong>wichtiger</strong> Artikel.</p>"
|
||||||
|
let expected = "Dies ist ein wichtiger Artikel.\n"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_ComplexNestedTags() {
|
||||||
|
let html = "<div><h1>Titel</h1><p>Text mit <em>kursiv</em> und <strong>fett</strong>.</p></div>"
|
||||||
|
let expected = "Titel\nText mit kursiv und fett."
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_WithAttributes() {
|
||||||
|
let html = "<p class=\"important\" id=\"main\">Text mit Attributen</p>"
|
||||||
|
let expected = "Text mit Attributen\n"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_EmptyTags() {
|
||||||
|
let html = "<p></p><div>Inhalt</div><span></span>"
|
||||||
|
let expected = "\nInhalt\n"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_SelfClosingTags() {
|
||||||
|
let html = "<p>Text mit <br>Zeilenumbruch und <img src=\"test.jpg\"> Bild.</p>"
|
||||||
|
let expected = "Text mit \nZeilenumbruch und Bild.\n"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_NoTags() {
|
||||||
|
let plainText = "Dies ist normaler Text ohne HTML."
|
||||||
|
|
||||||
|
XCTAssertEqual(plainText.stripHTML, plainText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_EmptyString() {
|
||||||
|
let emptyString = ""
|
||||||
|
|
||||||
|
XCTAssertEqual(emptyString.stripHTML, emptyString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_OnlyTags() {
|
||||||
|
let onlyTags = "<p><div><span></span></div></p>"
|
||||||
|
let expected = "\n"
|
||||||
|
|
||||||
|
XCTAssertEqual(onlyTags.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - stripHTMLSimple Tests
|
||||||
|
|
||||||
|
func testStripHTMLSimple_BasicTags() {
|
||||||
|
let html = "<p>Text mit <strong>fett</strong>.</p>"
|
||||||
|
let expected = "Text mit fett."
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTMLSimple, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_HTMLEntities() {
|
||||||
|
let html = "<p>Text mit Leerzeichen, & Zeichen und "Anführungszeichen".</p>"
|
||||||
|
let expected = "Text mit Leerzeichen, & Zeichen und \"Anführungszeichen\"."
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTMLSimple, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_MoreEntities() {
|
||||||
|
let html = "<p><Tag> und 'Apostroph'</p>"
|
||||||
|
let expected = "<Tag> und 'Apostroph'"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTMLSimple, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_ComplexHTML() {
|
||||||
|
let html = "<div class=\"container\"><h1>Überschrift</h1><p>Absatz mit <em>kursiv</em> und <strong>fett</strong>.</p><ul><li>Liste 1</li><li>Liste 2</li></ul></div>"
|
||||||
|
let expected = "Überschrift\nAbsatz mit kursiv und fett.\nListe 1\nListe 2"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTMLSimple, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_NoTags() {
|
||||||
|
let plainText = "Normaler Text ohne HTML."
|
||||||
|
|
||||||
|
XCTAssertEqual(plainText.stripHTMLSimple, plainText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_EmptyString() {
|
||||||
|
let emptyString = ""
|
||||||
|
|
||||||
|
XCTAssertEqual(emptyString.stripHTMLSimple, emptyString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_WhitespaceHandling() {
|
||||||
|
let html = " <p> Text mit Whitespace </p> "
|
||||||
|
let expected = "Text mit Whitespace"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTMLSimple, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Performance Tests
|
||||||
|
|
||||||
|
func testStripHTML_Performance() {
|
||||||
|
let largeHTML = String(repeating: "<p>Dies ist ein Test mit <strong>vielen</strong> <em>HTML</em> Tags.</p>", count: 1000)
|
||||||
|
|
||||||
|
measure {
|
||||||
|
_ = largeHTML.stripHTML
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTMLSimple_Performance() {
|
||||||
|
let largeHTML = String(repeating: "<p>Dies ist ein Test mit <strong>vielen</strong> <em>HTML</em> Tags.</p>", count: 1000)
|
||||||
|
|
||||||
|
measure {
|
||||||
|
_ = largeHTML.stripHTMLSimple
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Edge Cases
|
||||||
|
|
||||||
|
func testStripHTML_MalformedHTML() {
|
||||||
|
let malformed = "<p>Unvollständiger <strong>Tag"
|
||||||
|
let expected = "Unvollständiger Tag"
|
||||||
|
|
||||||
|
XCTAssertEqual(malformed.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_UnicodeCharacters() {
|
||||||
|
let html = "<p>Text mit Umlauten: äöüß und Emojis: 🚀📱</p>"
|
||||||
|
let expected = "Text mit Umlauten: äöüß und Emojis: 🚀📱"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_Newlines() {
|
||||||
|
let html = "<p>Erste Zeile<br>Zweite Zeile</p>"
|
||||||
|
let expected = "Erste Zeile\nZweite Zeile"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStripHTML_ListItems() {
|
||||||
|
let html = "<ul><li>Erster Punkt</li><li>Zweiter Punkt</li><li>Dritter Punkt</li></ul>"
|
||||||
|
let expected = "Erster Punkt\nZweiter Punkt\nDritter Punkt"
|
||||||
|
|
||||||
|
XCTAssertEqual(html.stripHTML, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user