diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..cdbd9ed
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "editor.fontSize": 14
+}
\ No newline at end of file
diff --git a/Localizable.xcstrings b/Localizable.xcstrings
index ac9c214..dbfd96b 100644
--- a/Localizable.xcstrings
+++ b/Localizable.xcstrings
@@ -3,12 +3,31 @@
"strings" : {
"" : {
+ },
+ "%@ (%lld)" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@ (%2$lld)"
+ }
+ }
+ }
+ },
+ "%lld" : {
+
+ },
+ "%lld Artikel in Queue" : {
+
},
"%lld min" : {
},
"%lld Minuten" : {
+ },
+ "%lld." : {
+
},
"12 min • Today • example.com" : {
@@ -44,6 +63,9 @@
},
"Artikel automatisch als gelesen markieren" : {
+ },
+ "Artikel vorlesen" : {
+
},
"Automatischer Sync" : {
@@ -107,6 +129,9 @@
},
"Fehler" : {
+ },
+ "Fehler: %@" : {
+
},
"Fertig" : {
@@ -137,6 +162,9 @@
},
"Item at %@" : {
+ },
+ "Keine Artikel in der Queue" : {
+
},
"Keine Bookmarks" : {
@@ -266,6 +294,9 @@
},
"Version %@" : {
+ },
+ "Vorlese-Queue" : {
+
},
"Vorschau" : {
diff --git a/readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift b/readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift
new file mode 100644
index 0000000..d82973c
--- /dev/null
+++ b/readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift
@@ -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)
+ }
+}
diff --git a/readeck/Domain/UseCase/ReadBookmarkUseCase.swift b/readeck/Domain/UseCase/ReadBookmarkUseCase.swift
new file mode 100644
index 0000000..8c64f8b
--- /dev/null
+++ b/readeck/Domain/UseCase/ReadBookmarkUseCase.swift
@@ -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)
+ }
+}
diff --git a/readeck/Info.plist b/readeck/Info.plist
index cb46d58..29aeaca 100644
--- a/readeck/Info.plist
+++ b/readeck/Info.plist
@@ -16,9 +16,13 @@
UILaunchScreen
UIColorName
- green
+ green2
UIImageName
readeck
+ UIBackgroundModes
+
+ audio
+
diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift
index 9948975..64381a5 100644
--- a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift
+++ b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift
@@ -225,6 +225,16 @@ struct BookmarkDetailView: View {
.foregroundColor(.secondary)
}
}
+
+ metaRow(icon: "speaker.wave.2") {
+ Button(action: {
+ viewModel.addBookmarkToSpeechQueue()
+ }) {
+ Text("Artikel vorlesen")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ }
}
}
diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift
index 2006c41..ba064a2 100644
--- a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift
+++ b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift
@@ -6,6 +6,7 @@ class BookmarkDetailViewModel {
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
private let loadSettingsUseCase: LoadSettingsUseCase
private let updateBookmarkUseCase: UpdateBookmarkUseCase
+ private let addTextToSpeechQueueUseCase: AddTextToSpeechQueueUseCase
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = ""
@@ -22,6 +23,7 @@ class BookmarkDetailViewModel {
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
+ self.addTextToSpeechQueueUseCase = factory.makeAddTextToSpeechQueueUseCase()
}
@MainActor
@@ -78,4 +80,9 @@ class BookmarkDetailViewModel {
func refreshBookmarkDetail(id: String) async {
await loadBookmarkDetail(id: id)
}
+
+ func addBookmarkToSpeechQueue() {
+ bookmarkDetail.content = articleContent
+ addTextToSpeechQueueUseCase.execute(bookmarkDetail: bookmarkDetail)
+ }
}
diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift
index 0900bda..c759093 100644
--- a/readeck/UI/Menu/PhoneTabView.swift
+++ b/readeck/UI/Menu/PhoneTabView.swift
@@ -15,42 +15,49 @@ struct PhoneTabView: View {
@State private var selectedTabIndex: Int = 0
var body: some View {
- TabView(selection: $selectedTabIndex) {
- ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
- NavigationStack {
- tabView(for: tab)
- }
- .tabItem {
- Label(tab.label, systemImage: tab.systemImage)
- }
- .tag(idx)
- }
-
- NavigationStack {
- List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
- NavigationLink(tag: tab, selection: $selectedMoreTab) {
+ GlobalPlayerContainerView {
+ TabView(selection: $selectedTabIndex) {
+ ForEach(Array(mainTabs.enumerated()), id: \.element) { idx, tab in
+ NavigationStack {
tabView(for: tab)
- .navigationTitle(tab.label)
- } label: {
+ }
+ .tabItem {
Label(tab.label, systemImage: tab.systemImage)
}
- .listRowBackground(Color(R.color.bookmark_list_bg))
+ .tag(idx)
}
- .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
+
+ NavigationStack {
+ if let selectedTab = selectedMoreTab {
+ tabView(for: selectedTab)
+ .navigationTitle(selectedTab.label)
+ } else {
+ List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
+ NavigationLink {
+ tabView(for: tab)
+ .navigationTitle(tab.label)
+ } 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
@@ -75,7 +82,7 @@ struct PhoneTabView: View {
case .pictures:
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
case .tags:
- Text("Tags")
+ LabelsView()
}
}
}
diff --git a/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift b/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift
new file mode 100644
index 0000000..f5f69ce
--- /dev/null
+++ b/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift
@@ -0,0 +1,39 @@
+import SwiftUI
+
+struct GlobalPlayerContainerView: 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))
+ }
+}
diff --git a/readeck/UI/SpeechPlayer/SpeechPlayerView.swift b/readeck/UI/SpeechPlayer/SpeechPlayerView.swift
new file mode 100644
index 0000000..16b7707
--- /dev/null
+++ b/readeck/UI/SpeechPlayer/SpeechPlayerView.swift
@@ -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()
+}
diff --git a/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift b/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift
new file mode 100644
index 0000000..fe5d1da
--- /dev/null
+++ b/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift
@@ -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()
+ }
+}
diff --git a/readeck/Utils/SpeechQueue.swift b/readeck/Utils/SpeechQueue.swift
new file mode 100644
index 0000000..4dc66c8
--- /dev/null
+++ b/readeck/Utils/SpeechQueue.swift
@@ -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()
+ }
+ }
+}
diff --git a/readeck/Utils/StringExtensions.swift b/readeck/Utils/StringExtensions.swift
new file mode 100644
index 0000000..1c71de0
--- /dev/null
+++ b/readeck/Utils/StringExtensions.swift
@@ -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)
+ }
+}
\ No newline at end of file
diff --git a/readeck/Utils/TTSManager.swift b/readeck/Utils/TTSManager.swift
new file mode 100644
index 0000000..d5f4c78
--- /dev/null
+++ b/readeck/Utils/TTSManager.swift
@@ -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)")
+ }
+ }
+}
diff --git a/readeckTests/StringExtensionsTests.swift b/readeckTests/StringExtensionsTests.swift
new file mode 100644
index 0000000..257c293
--- /dev/null
+++ b/readeckTests/StringExtensionsTests.swift
@@ -0,0 +1,158 @@
+import XCTest
+@testable import readeck
+
+final class StringExtensionsTests: XCTestCase {
+
+ // MARK: - stripHTML Tests
+
+ func testStripHTML_SimpleTags() {
+ let html = "Dies ist ein wichtiger Artikel.
"
+ let expected = "Dies ist ein wichtiger Artikel.\n"
+
+ XCTAssertEqual(html.stripHTML, expected)
+ }
+
+ func testStripHTML_ComplexNestedTags() {
+ let html = "Titel
Text mit kursiv und fett.
"
+ let expected = "Titel\nText mit kursiv und fett."
+
+ XCTAssertEqual(html.stripHTML, expected)
+ }
+
+ func testStripHTML_WithAttributes() {
+ let html = "Text mit Attributen
"
+ let expected = "Text mit Attributen\n"
+
+ XCTAssertEqual(html.stripHTML, expected)
+ }
+
+ func testStripHTML_EmptyTags() {
+ let html = "Inhalt
"
+ let expected = "\nInhalt\n"
+
+ XCTAssertEqual(html.stripHTML, expected)
+ }
+
+ func testStripHTML_SelfClosingTags() {
+ let html = "Text mit
Zeilenumbruch und
Bild.
"
+ 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 = "
"
+ let expected = "\n"
+
+ XCTAssertEqual(onlyTags.stripHTML, expected)
+ }
+
+ // MARK: - stripHTMLSimple Tests
+
+ func testStripHTMLSimple_BasicTags() {
+ let html = "Text mit fett.
"
+ let expected = "Text mit fett."
+
+ XCTAssertEqual(html.stripHTMLSimple, expected)
+ }
+
+ func testStripHTMLSimple_HTMLEntities() {
+ let html = "Text mit Leerzeichen, & Zeichen und "Anführungszeichen".
"
+ let expected = "Text mit Leerzeichen, & Zeichen und \"Anführungszeichen\"."
+
+ XCTAssertEqual(html.stripHTMLSimple, expected)
+ }
+
+ func testStripHTMLSimple_MoreEntities() {
+ let html = "<Tag> und 'Apostroph'
"
+ let expected = " und 'Apostroph'"
+
+ XCTAssertEqual(html.stripHTMLSimple, expected)
+ }
+
+ func testStripHTMLSimple_ComplexHTML() {
+ let html = "Überschrift
Absatz mit kursiv und fett.
"
+ 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 = " Text mit Whitespace
"
+ let expected = "Text mit Whitespace"
+
+ XCTAssertEqual(html.stripHTMLSimple, expected)
+ }
+
+ // MARK: - Performance Tests
+
+ func testStripHTML_Performance() {
+ let largeHTML = String(repeating: "Dies ist ein Test mit vielen HTML Tags.
", count: 1000)
+
+ measure {
+ _ = largeHTML.stripHTML
+ }
+ }
+
+ func testStripHTMLSimple_Performance() {
+ let largeHTML = String(repeating: "Dies ist ein Test mit vielen HTML Tags.
", count: 1000)
+
+ measure {
+ _ = largeHTML.stripHTMLSimple
+ }
+ }
+
+ // MARK: - Edge Cases
+
+ func testStripHTML_MalformedHTML() {
+ let malformed = "Unvollständiger Tag"
+ let expected = "Unvollständiger Tag"
+
+ XCTAssertEqual(malformed.stripHTML, expected)
+ }
+
+ func testStripHTML_UnicodeCharacters() {
+ let html = "Text mit Umlauten: äöüß und Emojis: 🚀📱
"
+ let expected = "Text mit Umlauten: äöüß und Emojis: 🚀📱"
+
+ XCTAssertEqual(html.stripHTML, expected)
+ }
+
+ func testStripHTML_Newlines() {
+ let html = "Erste Zeile
Zweite Zeile
"
+ let expected = "Erste Zeile\nZweite Zeile"
+
+ XCTAssertEqual(html.stripHTML, expected)
+ }
+
+ func testStripHTML_ListItems() {
+ let html = "- Erster Punkt
- Zweiter Punkt
- Dritter Punkt
"
+ let expected = "Erster Punkt\nZweiter Punkt\nDritter Punkt"
+
+ XCTAssertEqual(html.stripHTML, expected)
+ }
+}