From e68959afcecedbca39aad853b5987c6abb41f34d Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Mon, 14 Jul 2025 21:34:39 +0200 Subject: [PATCH] Refactor: Move Utils to UI/Utils, improve SpeechPlayer UI, enhance state management, remove legacy files, and optimize queue handling - Move and replace utility files (SafariUtil, SpeechQueue, StringExtensions, TTSManager, VoiceManager) - Refactor and extend SpeechPlayer components (UI, progress, volume, queue) - Improved state and EnvironmentObject management (PlayerUIState) - UI and logic optimizations in menu and tab views - Remove obsolete and duplicate files - General code and UX improvements --- Localizable.xcstrings | 42 ++- ...ence.swift => PersistenceController.swift} | 0 .../Data/Repository/BookmarksRepository.swift | 13 +- readeck/Domain/Model/BookmarkDetail.swift | 4 +- .../Protocols/PBookmarksRepository.swift | 16 + .../UseCase/AddTextToSpeechQueueUseCase.swift | 2 +- .../BookmarkDetail/BookmarkDetailView.swift | 3 +- readeck/UI/Bookmarks/BookmarksView.swift | 1 + readeck/UI/ContentView.swift | 88 ------ readeck/UI/Labels/LabelsView.swift | 1 - readeck/UI/Menu/PadSidebarView.swift | 71 ++--- readeck/UI/Menu/PhoneTabView.swift | 25 +- readeck/UI/Menu/PlayerQueueResumeButton.swift | 51 ++++ readeck/UI/Menu/TabView.swift | 3 + .../GlobalPlayerContainerView.swift | 11 +- readeck/UI/SpeechPlayer/PlayerUIState.swift | 18 ++ .../UI/SpeechPlayer/SpeechPlayerView.swift | 289 ++++++++++++------ .../SpeechPlayer/SpeechPlayerViewModel.swift | 43 ++- readeck/{ => UI}/Utils/SafariUtil.swift | 0 readeck/UI/Utils/SpeechQueue.swift | 153 ++++++++++ readeck/{ => UI}/Utils/StringExtensions.swift | 0 readeck/{ => UI}/Utils/TTSManager.swift | 77 ++++- readeck/{ => UI}/Utils/VoiceManager.swift | 0 readeck/UI/readeckApp.swift | 29 -- readeck/Utils/SpeechQueue.swift | 76 ----- 25 files changed, 643 insertions(+), 373 deletions(-) rename readeck/Data/{Persistence.swift => PersistenceController.swift} (100%) create mode 100644 readeck/Domain/Protocols/PBookmarksRepository.swift delete mode 100644 readeck/UI/ContentView.swift create mode 100644 readeck/UI/Menu/PlayerQueueResumeButton.swift create mode 100644 readeck/UI/SpeechPlayer/PlayerUIState.swift rename readeck/{ => UI}/Utils/SafariUtil.swift (100%) create mode 100644 readeck/UI/Utils/SpeechQueue.swift rename readeck/{ => UI}/Utils/StringExtensions.swift (100%) rename readeck/{ => UI}/Utils/TTSManager.swift (62%) rename readeck/{ => UI}/Utils/VoiceManager.swift (100%) delete mode 100644 readeck/Utils/SpeechQueue.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index dbfd96b..1e7c160 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -17,7 +17,7 @@ "%lld" : { }, - "%lld Artikel in Queue" : { + "%lld Artikel in der Queue" : { }, "%lld min" : { @@ -28,6 +28,16 @@ }, "%lld." : { + }, + "%lld/%lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld/%2$lld" + } + } + } }, "12 min • Today • example.com" : { @@ -37,9 +47,6 @@ }, "Abmelden" : { - }, - "Add Item" : { - }, "Aktuelle Labels" : { @@ -138,12 +145,18 @@ }, "Fertig mit Lesen?" : { + }, + "Fortschritt: %lld%%" : { + }, "Füge einen neuen Link zu deiner Sammlung hinzu" : { }, "Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." : { + }, + "Geschwindigkeit" : { + }, "https://example.com" : { @@ -159,9 +172,6 @@ }, "Ihre aktuelle Server-Verbindung und Anmeldedaten." : { - }, - "Item at %@" : { - }, "Keine Artikel in der Queue" : { @@ -195,6 +205,16 @@ }, "Lade Artikel..." : { + }, + "Lese %lld/%lld: " : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Lese %1$lld/%2$lld: " + } + } + } }, "Leseeinstellungen" : { @@ -244,10 +264,7 @@ "Schriftgröße" : { }, - "Select a bookmark" : { - - }, - "Select an item" : { + "Select a bookmark or tag" : { }, "Server-Endpunkt" : { @@ -303,6 +320,9 @@ }, "Website" : { + }, + "Weiterhören" : { + }, "Wiederherstellen" : { diff --git a/readeck/Data/Persistence.swift b/readeck/Data/PersistenceController.swift similarity index 100% rename from readeck/Data/Persistence.swift rename to readeck/Data/PersistenceController.swift diff --git a/readeck/Data/Repository/BookmarksRepository.swift b/readeck/Data/Repository/BookmarksRepository.swift index b52bbde..f2e29bb 100644 --- a/readeck/Data/Repository/BookmarksRepository.swift +++ b/readeck/Data/Repository/BookmarksRepository.swift @@ -1,15 +1,5 @@ import Foundation -protocol PBookmarksRepository { - func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPage - func fetchBookmark(id: String) async throws -> BookmarkDetail - func fetchBookmarkArticle(id: String) async throws -> String - func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String - func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws - func deleteBookmark(id: String) async throws - func searchBookmarks(search: String) async throws -> BookmarksPage -} - class BookmarksRepository: PBookmarksRepository { private var api: PAPI @@ -40,7 +30,8 @@ class BookmarksRepository: PBookmarksRepository { isArchived: bookmarkDetailDto.isArchived, labels: bookmarkDetailDto.labels, thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "", - imageUrl: bookmarkDetailDto.resources.image?.src ?? "" + imageUrl: bookmarkDetailDto.resources.image?.src ?? "", + lang: bookmarkDetailDto.lang ?? "" ) } diff --git a/readeck/Domain/Model/BookmarkDetail.swift b/readeck/Domain/Model/BookmarkDetail.swift index c2ecb67..62c1f3b 100644 --- a/readeck/Domain/Model/BookmarkDetail.swift +++ b/readeck/Domain/Model/BookmarkDetail.swift @@ -17,6 +17,7 @@ struct BookmarkDetail { let labels: [String] let thumbnailUrl: String let imageUrl: String + let lang: String var content: String? } @@ -37,6 +38,7 @@ extension BookmarkDetail { isArchived: false, labels: [], thumbnailUrl: "", - imageUrl: "" + imageUrl: "", + lang: "" ) } diff --git a/readeck/Domain/Protocols/PBookmarksRepository.swift b/readeck/Domain/Protocols/PBookmarksRepository.swift new file mode 100644 index 0000000..4d25fd1 --- /dev/null +++ b/readeck/Domain/Protocols/PBookmarksRepository.swift @@ -0,0 +1,16 @@ +// +// PBookmarksRepository.swift +// readeck +// +// Created by Ilyas Hallak on 14.07.25. +// + +protocol PBookmarksRepository { + func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPage + func fetchBookmark(id: String) async throws -> BookmarkDetail + func fetchBookmarkArticle(id: String) async throws -> String + func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String + func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws + func deleteBookmark(id: String) async throws + func searchBookmarks(search: String) async throws -> BookmarksPage +} diff --git a/readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift b/readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift index d82973c..9d86e91 100644 --- a/readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift +++ b/readeck/Domain/UseCase/AddTextToSpeechQueueUseCase.swift @@ -14,6 +14,6 @@ class AddTextToSpeechQueueUseCase { } else { text += bookmarkDetail.description.stripHTML } - speechQueue.enqueue(text) + speechQueue.enqueue(bookmarkDetail.toSpeechQueueItem(text)) } } diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift index 64381a5..0481f92 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift @@ -7,6 +7,7 @@ struct BookmarkDetailView: View { @State private var webViewHeight: CGFloat = 300 @State private var showingFontSettings = false @State private var showingLabelsSheet = false + @EnvironmentObject var playerUIState: PlayerUIState private let headerHeight: CGFloat = 320 @@ -229,6 +230,7 @@ struct BookmarkDetailView: View { metaRow(icon: "speaker.wave.2") { Button(action: { viewModel.addBookmarkToSpeechQueue() + playerUIState.showPlayer() }) { Text("Artikel vorlesen") .font(.subheadline) @@ -238,7 +240,6 @@ struct BookmarkDetailView: View { } } - // ViewBuilder für Meta-Infos @ViewBuilder private func metaRow(icon: String, text: String) -> some View { HStack { diff --git a/readeck/UI/Bookmarks/BookmarksView.swift b/readeck/UI/Bookmarks/BookmarksView.swift index 32e7ebb..3a82bcd 100644 --- a/readeck/UI/Bookmarks/BookmarksView.swift +++ b/readeck/UI/Bookmarks/BookmarksView.swift @@ -16,6 +16,7 @@ struct BookmarksView: View { let state: BookmarkState let type: [BookmarkType] @Binding var selectedBookmark: Bookmark? + @EnvironmentObject var playerUIState: PlayerUIState let tag: String? // MARK: Initializer diff --git a/readeck/UI/ContentView.swift b/readeck/UI/ContentView.swift deleted file mode 100644 index 4211012..0000000 --- a/readeck/UI/ContentView.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// ContentView.swift -// readeck -// -// Created by Ilyas Hallak on 10.06.25. -// - -import SwiftUI -import CoreData - -struct ContentView: View { - @Environment(\.managedObjectContext) private var viewContext - - @FetchRequest( - sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)], - animation: .default) - private var items: FetchedResults - - var body: some View { - NavigationView { - List { - ForEach(items) { item in - NavigationLink { - Text("Item at \(item.timestamp!, formatter: itemFormatter)") - } label: { - Text(item.timestamp!, formatter: itemFormatter) - } - } - .onDelete(perform: deleteItems) - } - .toolbar { -#if os(iOS) - ToolbarItem(placement: .navigationBarTrailing) { - EditButton() - } -#endif - ToolbarItem { - Button(action: addItem) { - Label("Add Item", systemImage: "plus") - } - } - } - Text("Select an item") - } - } - - private func addItem() { - withAnimation { - let newItem = Item(context: viewContext) - newItem.timestamp = Date() - - do { - try viewContext.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") - } - } - } - - private func deleteItems(offsets: IndexSet) { - withAnimation { - offsets.map { items[$0] }.forEach(viewContext.delete) - - do { - try viewContext.save() - } catch { - // Replace this implementation with code to handle the error appropriately. - // fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development. - let nsError = error as NSError - fatalError("Unresolved error \(nsError), \(nsError.userInfo)") - } - } - } -} - -private let itemFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .short - formatter.timeStyle = .medium - return formatter -}() - -#Preview { - ContentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) -} diff --git a/readeck/UI/Labels/LabelsView.swift b/readeck/UI/Labels/LabelsView.swift index 5be0621..41b8bf9 100644 --- a/readeck/UI/Labels/LabelsView.swift +++ b/readeck/UI/Labels/LabelsView.swift @@ -15,7 +15,6 @@ struct LabelsView: View { } else { List { ForEach(viewModel.labels, id: \.href) { label in - NavigationLink { BookmarksView(state: .all, type: [], selectedBookmark: .constant(nil), tag: label.name) .navigationTitle("\(label.name) (\(label.count))") diff --git a/readeck/UI/Menu/PadSidebarView.swift b/readeck/UI/Menu/PadSidebarView.swift index ceca7cd..6fc8122 100644 --- a/readeck/UI/Menu/PadSidebarView.swift +++ b/readeck/UI/Menu/PadSidebarView.swift @@ -10,6 +10,8 @@ import SwiftUI struct PadSidebarView: View { @State private var selectedTab: SidebarTab = .unread @State private var selectedBookmark: Bookmark? + @State private var selectedTag: BookmarkLabel? + @EnvironmentObject var playerUIState: PlayerUIState private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags] @@ -19,6 +21,8 @@ struct PadSidebarView: View { ForEach(sidebarTabs, id: \.self) { tab in Button(action: { selectedTab = tab + selectedBookmark = nil + selectedTag = nil }) { Label(tab.label, systemImage: tab.systemImage) .foregroundColor(selectedTab == tab ? .accentColor : .primary) @@ -31,17 +35,7 @@ struct PadSidebarView: View { Spacer() .listRowBackground(Color(R.color.menu_sidebar_bg)) } - - if tab == .pictures { - Group { - Spacer() - Divider() - Spacer() - } - .listRowBackground(Color(R.color.menu_sidebar_bg)) - - } - } + } } .listRowBackground(Color(R.color.menu_sidebar_bg)) .background(Color(R.color.menu_sidebar_bg)) @@ -49,7 +43,6 @@ struct PadSidebarView: View { .scrollContentBackground(.hidden) .safeAreaInset(edge: .bottom, alignment: .center) { VStack(spacing: 0) { - Divider() Button(action: { selectedTab = .settings }) { @@ -60,43 +53,45 @@ struct PadSidebarView: View { .contentShape(Rectangle()) } .listRowBackground(selectedTab == .settings ? Color.accentColor.opacity(0.15) : Color(R.color.menu_sidebar_bg)) + PlayerQueueResumeButton() + .padding(.top, 8) } .padding(.horizontal, 12) .background(Color(R.color.menu_sidebar_bg)) } } content: { - Group { - switch selectedTab { - case .search: - SearchBookmarksView(selectedBookmark: $selectedBookmark) - case .all: - BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) - case .unread: - BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark) - case .favorite: - BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark) - case .archived: - BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark) - case .settings: - SettingsView() - case .article: - BookmarksView(state: .all, type: [.article], selectedBookmark: $selectedBookmark) - case .videos: - BookmarksView(state: .all, type: [.video], selectedBookmark: $selectedBookmark) - case .pictures: - BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark) - case .tags: - LabelsView() + GlobalPlayerContainerView { + Group { + switch selectedTab { + case .search: + SearchBookmarksView(selectedBookmark: $selectedBookmark) + case .all: + BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) + case .unread: + BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark) + case .favorite: + BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark) + case .archived: + BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark) + case .settings: + SettingsView() + case .article: + BookmarksView(state: .all, type: [.article], selectedBookmark: $selectedBookmark) + case .videos: + BookmarksView(state: .all, type: [.video], selectedBookmark: $selectedBookmark) + case .pictures: + BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark) + case .tags: + LabelsView() + } } + .navigationTitle(selectedTab.label) } - .navigationTitle(selectedTab.label) - - } detail: { if let bookmark = selectedBookmark, selectedTab != .settings { BookmarkDetailView(bookmarkId: bookmark.id) } else { - Text(selectedTab == .settings ? "" : "Select a bookmark") + Text(selectedTab == .settings ? "" : "Select a bookmark or tag") .foregroundColor(.gray) } } diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift index c759093..2835a9c 100644 --- a/readeck/UI/Menu/PhoneTabView.swift +++ b/readeck/UI/Menu/PhoneTabView.swift @@ -32,18 +32,23 @@ struct PhoneTabView: View { 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) + VStack(alignment: .leading) { + 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)) } - .listRowBackground(Color(R.color.bookmark_list_bg)) + .navigationTitle("Mehr") + .scrollContentBackground(.hidden) + .background(Color(R.color.bookmark_list_bg)) + + PlayerQueueResumeButton() + .padding(.bottom, 16) } - .navigationTitle("Mehr") - .scrollContentBackground(.hidden) - .background(Color(R.color.bookmark_list_bg)) } } .tabItem { diff --git a/readeck/UI/Menu/PlayerQueueResumeButton.swift b/readeck/UI/Menu/PlayerQueueResumeButton.swift new file mode 100644 index 0000000..8153550 --- /dev/null +++ b/readeck/UI/Menu/PlayerQueueResumeButton.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct PlayerQueueResumeButton: View { + @ObservedObject private var queue = SpeechQueue.shared + @EnvironmentObject var playerUIState: PlayerUIState + private let playerViewModel = SpeechPlayerViewModel() + + var body: some View { + if queue.hasItems, !playerUIState.isPlayerVisible { + Button(action: { + playerViewModel.resume() + playerUIState.showPlayer() + }) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Vorlese-Queue") + .font(.caption2) + .foregroundColor(.secondary) + Text("\(queue.queueItems.count) Artikel in der Queue") + .font(.subheadline) + .foregroundColor(.primary) + } + Spacer() + Button(action: { + playerViewModel.resume() + playerUIState.showPlayer() + }) { + Text("Weiterhören") + .font(.subheadline) + .fontWeight(.semibold) + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background(Color.accentColor.opacity(0.15)) + .foregroundColor(.accentColor) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(.vertical, 14) + .padding(.horizontal, 20) + } + .buttonStyle(PlainButtonStyle()) + .background(Color(.systemBackground)) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color(.systemBackground)) + .padding(.bottom, 8) + .transition(.opacity) + .animation(.spring(), value: queue.hasItems) + } + } +} \ No newline at end of file diff --git a/readeck/UI/Menu/TabView.swift b/readeck/UI/Menu/TabView.swift index 5f3a612..d3350ef 100644 --- a/readeck/UI/Menu/TabView.swift +++ b/readeck/UI/Menu/TabView.swift @@ -4,6 +4,7 @@ import Foundation struct MainTabView: View { @State private var selectedTab: SidebarTab = .unread @State var selectedBookmark: Bookmark? + @StateObject private var playerUIState = PlayerUIState() // sizeClass @Environment(\.horizontalSizeClass) @@ -15,8 +16,10 @@ struct MainTabView: View { var body: some View { if UIDevice.isPhone { PhoneTabView() + .environmentObject(playerUIState) } else { PadSidebarView() + .environmentObject(playerUIState) } } } diff --git a/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift b/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift index a629987..d45f6c1 100644 --- a/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift +++ b/readeck/UI/SpeechPlayer/GlobalPlayerContainerView.swift @@ -3,23 +3,23 @@ import SwiftUI struct GlobalPlayerContainerView: View { let content: Content @StateObject private var viewModel = SpeechPlayerViewModel() - + @EnvironmentObject var playerUIState: PlayerUIState + init(@ViewBuilder content: () -> Content) { self.content = content() } - + var body: some View { ZStack(alignment: .bottom) { content .frame(maxWidth: .infinity, maxHeight: .infinity) - if viewModel.hasItems { + if viewModel.hasItems && playerUIState.isPlayerVisible { VStack(spacing: 0) { - SpeechPlayerView() + SpeechPlayerView(onClose: { playerUIState.hidePlayer() }) .padding(.horizontal, 16) .padding(.vertical, 8) .transition(.move(edge: .bottom).combined(with: .opacity)) - Rectangle() .fill(.clear) .frame(height: 49) @@ -36,4 +36,5 @@ struct GlobalPlayerContainerView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemBackground)) } + .environmentObject(PlayerUIState()) } diff --git a/readeck/UI/SpeechPlayer/PlayerUIState.swift b/readeck/UI/SpeechPlayer/PlayerUIState.swift new file mode 100644 index 0000000..1e6fa03 --- /dev/null +++ b/readeck/UI/SpeechPlayer/PlayerUIState.swift @@ -0,0 +1,18 @@ +import Foundation +import Combine + +class PlayerUIState: ObservableObject { + @Published var isPlayerVisible: Bool = false + + func showPlayer() { + isPlayerVisible = true + } + + func hidePlayer() { + isPlayerVisible = false + } + + func togglePlayer() { + isPlayerVisible.toggle() + } +} \ No newline at end of file diff --git a/readeck/UI/SpeechPlayer/SpeechPlayerView.swift b/readeck/UI/SpeechPlayer/SpeechPlayerView.swift index 591764b..ef862bd 100644 --- a/readeck/UI/SpeechPlayer/SpeechPlayerView.swift +++ b/readeck/UI/SpeechPlayer/SpeechPlayerView.swift @@ -4,16 +4,17 @@ 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 = 300 + private let maxHeight: CGFloat = UIScreen.main.bounds.height / 2 var body: some View { VStack(spacing: 0) { if isExpanded { - expandedView + ExpandedPlayerView(viewModel: viewModel, isExpanded: $isExpanded, onClose: onClose) } else { - collapsedView + CollapsedPlayerBar(viewModel: viewModel, isExpanded: $isExpanded) } } .frame(height: isExpanded ? maxHeight : minHeight) @@ -38,10 +39,14 @@ struct SpeechPlayerView: View { } ) } +} + +private struct CollapsedPlayerBar: View { + @ObservedObject var viewModel: SpeechPlayerViewModel + @Binding var isExpanded: Bool - private var collapsedView: some View { + var body: some View { HStack(spacing: 16) { - // Play/Pause Button Button(action: { if viewModel.isSpeaking { viewModel.pause() @@ -53,43 +58,37 @@ struct SpeechPlayerView: View { .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 viewModel.articleProgress > 0 && viewModel.articleProgress < 1 { + ProgressView(value: viewModel.articleProgress) + .progressViewStyle(LinearProgressViewStyle(tint: .accentColor)) + .scaleEffect(y: 0.8) + } if viewModel.queueCount > 0 { - Text("\(viewModel.queueCount) Artikel in Queue") - .font(.caption) - .foregroundColor(.secondary) + 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() - } + withAnimation(.spring()) { isExpanded.toggle() } } - Spacer() - - // Stop Button - Button(action: { - viewModel.stop() - }) { + Button(action: { viewModel.stop() }) { Image(systemName: "stop.fill") .font(.title3) .foregroundColor(.secondary) } - - // Expand Button - Button(action: { - withAnimation(.spring()) { - isExpanded.toggle() - } - }) { + Button(action: { withAnimation(.spring()) { isExpanded.toggle() } }) { Image(systemName: "chevron.up") .font(.caption) .foregroundColor(.secondary) @@ -98,22 +97,28 @@ struct SpeechPlayerView: View { .padding(.horizontal, 16) .padding(.vertical, 12) } +} + +private struct ExpandedPlayerView: View { + @ObservedObject var viewModel: SpeechPlayerViewModel + @Binding var isExpanded: Bool + var onClose: (() -> Void)? = nil - private var expandedView: some View { + var body: some View { VStack(spacing: 16) { // Header HStack { + Button(action: { onClose?() }) { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundColor(.secondary) + } + Spacer() Text("Vorlese-Queue") .font(.headline) .fontWeight(.semibold) - Spacer() - - Button(action: { - withAnimation(.spring()) { - isExpanded = false - } - }) { + Button(action: { withAnimation(.spring()) { isExpanded = false } }) { Image(systemName: "chevron.down") .font(.title3) .foregroundColor(.secondary) @@ -121,68 +126,178 @@ struct SpeechPlayerView: View { } .padding(.horizontal, 16) .padding(.top, 16) - - // Controls - HStack(spacing: 24) { - Button(action: { - if viewModel.isSpeaking { - viewModel.pause() - } else { - viewModel.resume() + // Fortschrittsbalken für aktuellen Artikel + if viewModel.articleProgress > 0 && viewModel.articleProgress < 1 { + VStack(spacing: 4) { + ProgressView(value: viewModel.articleProgress) + .progressViewStyle(LinearProgressViewStyle(tint: .accentColor)) + HStack { + Text("Fortschritt: \(Int(viewModel.articleProgress * 100))%") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() } - }) { - Image(systemName: viewModel.isSpeaking ? "pause.fill" : "play.fill") - .font(.title) + } + .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) - } - - Button(action: { - viewModel.stop() - }) { - Image(systemName: "stop.fill") - .font(.title2) + Text("Lese \(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) } - - // Queue List - if viewModel.queueCount == 0 { - Text("Keine Artikel in der 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) - .font(.subheadline) - .lineLimit(2) - .truncationMode(.tail) - - Spacer() - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(Color(.systemGray6)) - .cornerRadius(8) - } - } - .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("Geschwindigkeit", 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("Geschwindigkeit", 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("Keine Artikel in der 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() } diff --git a/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift b/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift index 8a9b10b..12a6c6b 100644 --- a/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift +++ b/readeck/UI/SpeechPlayer/SpeechPlayerViewModel.swift @@ -9,8 +9,14 @@ class SpeechPlayerViewModel: ObservableObject { @Published var isSpeaking: Bool = false @Published var currentText: String = "" @Published var queueCount: Int = 0 - @Published var queueItems: [String] = [] + @Published var queueItems: [SpeechQueueItem] = [] @Published var hasItems: Bool = false + @Published var progress: Double = 0.0 + @Published var currentUtteranceIndex: Int = 0 + @Published var totalUtterances: Int = 0 + @Published var articleProgress: Double = 0.0 + @Published var volume: Float = 1.0 + @Published var rate: Float = 0.5 init(ttsManager: TTSManager = .shared, speechQueue: SpeechQueue = .shared) { self.ttsManager = ttsManager @@ -41,6 +47,39 @@ class SpeechPlayerViewModel: ObservableObject { speechQueue.$hasItems .assign(to: \.hasItems, on: self) .store(in: &cancellables) + + // TTS Progress bindings + ttsManager.$progress + .assign(to: \.progress, on: self) + .store(in: &cancellables) + + ttsManager.$currentUtteranceIndex + .assign(to: \.currentUtteranceIndex, on: self) + .store(in: &cancellables) + + ttsManager.$totalUtterances + .assign(to: \.totalUtterances, on: self) + .store(in: &cancellables) + + ttsManager.$articleProgress + .assign(to: \.articleProgress, on: self) + .store(in: &cancellables) + + ttsManager.$volume + .assign(to: \.volume, on: self) + .store(in: &cancellables) + + ttsManager.$rate + .assign(to: \.rate, on: self) + .store(in: &cancellables) + } + + func setVolume(_ newVolume: Float) { + ttsManager.setVolume(newVolume) + } + + func setRate(_ newRate: Float) { + ttsManager.setRate(newRate) } func pause() { @@ -52,6 +91,6 @@ class SpeechPlayerViewModel: ObservableObject { } func stop() { - speechQueue.clear() + ttsManager.stop() } } diff --git a/readeck/Utils/SafariUtil.swift b/readeck/UI/Utils/SafariUtil.swift similarity index 100% rename from readeck/Utils/SafariUtil.swift rename to readeck/UI/Utils/SafariUtil.swift diff --git a/readeck/UI/Utils/SpeechQueue.swift b/readeck/UI/Utils/SpeechQueue.swift new file mode 100644 index 0000000..7e1d8e9 --- /dev/null +++ b/readeck/UI/Utils/SpeechQueue.swift @@ -0,0 +1,153 @@ +import Foundation +import Combine + +struct SpeechQueueItem: Codable, Equatable, Identifiable { + let id: String + let title: String + let content: String? + let url: String + let labels: [String]? + let imageUrl: String? +} + +extension BookmarkDetail { + func toSpeechQueueItem(_ content: String? = nil) -> SpeechQueueItem { + return SpeechQueueItem( + id: self.id, + title: title, + content: content ?? self.content, + url: url, + labels: labels, + imageUrl: imageUrl + ) + } +} + +class SpeechQueue: ObservableObject { + private var queue: [SpeechQueueItem] = [] + private var isProcessing = false + private let ttsManager: TTSManager + private let language: String + private let queueKey = "tts_queue" + + static let shared = SpeechQueue() + + @Published var queueItems: [SpeechQueueItem] = [] + @Published var currentText: String = "" + @Published var hasItems: Bool = false + + var queueCount: Int { + return queueItems.count + } + + var currentItem: SpeechQueueItem? { + return queueItems.first + } + + private init(ttsManager: TTSManager = .shared, language: String = "de-DE") { + self.ttsManager = ttsManager + self.language = language + loadQueue() + updatePublishedProperties() + } + + func enqueue(_ item: SpeechQueueItem) { + queue.append(item) + updatePublishedProperties() + saveQueue() + processQueue() + } + + func enqueue(contentsOf items: [SpeechQueueItem]) { + queue.append(contentsOf: items) + updatePublishedProperties() + saveQueue() + processQueue() + } + + func stop() { + print("[SpeechQueue] stop() aufgerufen") + updatePublishedProperties() + saveQueue() + ttsManager.stop() + isProcessing = false + } + + func clear() { + print("[SpeechQueue] clear() aufgerufen") + queue.removeAll() + updatePublishedProperties() + saveQueue() + ttsManager.stop() + isProcessing = false + } + + private func updatePublishedProperties() { + queueItems = queue + currentText = queue.first?.content ?? "" + hasItems = !queue.isEmpty || ttsManager.isCurrentlySpeaking() + } + + private func processQueue() { + guard !isProcessing, !queue.isEmpty else { return } + isProcessing = true + let next = queue[0] + updatePublishedProperties() + saveQueue() + let currentIndex = queueItems.count - queue.count + let textToSpeak = (next.title + "\n" + (next.content ?? "")).trimmingCharacters(in: .whitespacesAndNewlines) + ttsManager.speak(text: textToSpeak, language: language, utteranceIndex: currentIndex, totalUtterances: queueItems.count) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + self?.waitForSpeechToFinish() + } + } + + private func waitForSpeechToFinish() { + if ttsManager.isCurrentlySpeaking() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + self?.waitForSpeechToFinish() + } + } else { + if !queue.isEmpty { + queue.removeFirst() + print("[SpeechQueue] Artikel fertig abgespielt und aus Queue entfernt") + } + self.isProcessing = false + self.updatePublishedProperties() + self.saveQueue() + self.processQueue() + } + } + + // MARK: - Persistenz + private func saveQueue() { + let defaults = UserDefaults.standard + do { + let data = try JSONEncoder().encode(queue) + if let jsonString = String(data: data, encoding: .utf8) { + print("[SpeechQueue] Speichere Queue (\(queue.count)) als JSON: \n\(jsonString)") + } + defaults.set(data, forKey: queueKey) + } catch { + print("[SpeechQueue] Fehler beim Speichern der Queue:", error) + } + } + + private func loadQueue() { + let defaults = UserDefaults.standard + if let data = defaults.data(forKey: queueKey) { + do { + let savedQueue = try JSONDecoder().decode([SpeechQueueItem].self, from: data) + queue = savedQueue + print("[SpeechQueue] Queue geladen (", queue.count, ")") + } catch { + print("[SpeechQueue] Fehler beim Laden der Queue:", error) + defaults.removeObject(forKey: queueKey) + queue = [] + } + } + if queue.isEmpty { + print("[SpeechQueue] Queue ist nach dem Laden leer!") + } + } +} diff --git a/readeck/Utils/StringExtensions.swift b/readeck/UI/Utils/StringExtensions.swift similarity index 100% rename from readeck/Utils/StringExtensions.swift rename to readeck/UI/Utils/StringExtensions.swift diff --git a/readeck/Utils/TTSManager.swift b/readeck/UI/Utils/TTSManager.swift similarity index 62% rename from readeck/Utils/TTSManager.swift rename to readeck/UI/Utils/TTSManager.swift index 324f9a8..7850de8 100644 --- a/readeck/Utils/TTSManager.swift +++ b/readeck/UI/Utils/TTSManager.swift @@ -10,11 +10,18 @@ class TTSManager: NSObject, ObservableObject, AVSpeechSynthesizerDelegate { @Published var isSpeaking = false @Published var currentUtterance = "" + @Published var progress: Double = 0.0 + @Published var totalUtterances: Int = 0 + @Published var currentUtteranceIndex: Int = 0 + @Published var articleProgress: Double = 0.0 + @Published var volume: Float = 1.0 + @Published var rate: Float = 0.5 override private init() { super.init() synthesizer.delegate = self configureAudioSession() + loadSettings() } private func configureAudioSession() { @@ -22,16 +29,13 @@ class TTSManager: NSObject, ObservableObject, AVSpeechSynthesizerDelegate { let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP]) try audioSession.setActive(true) - try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP]) - NotificationCenter.default.addObserver( self, selector: #selector(handleAppDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil ) - NotificationCenter.default.addObserver( self, selector: #selector(handleAppWillEnterForeground), @@ -43,29 +47,61 @@ class TTSManager: NSObject, ObservableObject, AVSpeechSynthesizerDelegate { } } - func speak(text: String, language: String = "de-DE") { + func speak(text: String, language: String = "de-DE", utteranceIndex: Int = 0, totalUtterances: Int = 1) { guard !text.isEmpty else { return } - DispatchQueue.main.async { self.isSpeaking = true self.currentUtterance = text + self.currentUtteranceIndex = utteranceIndex + self.totalUtterances = totalUtterances + self.updateProgress() + self.articleProgress = 0.0 } - if synthesizer.isSpeaking { synthesizer.stopSpeaking(at: .immediate) } - let utterance = AVSpeechUtterance(string: text) - utterance.voice = voiceManager.getVoice(for: language) - - utterance.rate = 0.5 + utterance.rate = rate utterance.pitchMultiplier = 1.0 - utterance.volume = 1.0 - + utterance.volume = volume synthesizer.speak(utterance) } + private func updateProgress() { + if totalUtterances > 0 { + progress = Double(currentUtteranceIndex) / Double(totalUtterances) + } else { + progress = 0.0 + } + } + + func setVolume(_ newVolume: Float) { + volume = newVolume + saveSettings() + } + + func setRate(_ newRate: Float) { + rate = newRate + saveSettings() + } + + private func loadSettings() { + let defaults = UserDefaults.standard + if let savedVolume = defaults.value(forKey: "tts_volume") as? Float { + volume = savedVolume + } + if let savedRate = defaults.value(forKey: "tts_rate") as? Float { + rate = savedRate + } + } + + private func saveSettings() { + let defaults = UserDefaults.standard + defaults.set(volume, forKey: "tts_volume") + defaults.set(rate, forKey: "tts_rate") + } + func pause() { synthesizer.pauseSpeaking(at: .immediate) isSpeaking = false @@ -80,16 +116,22 @@ class TTSManager: NSObject, ObservableObject, AVSpeechSynthesizerDelegate { synthesizer.stopSpeaking(at: .immediate) isSpeaking = false currentUtterance = "" + articleProgress = 0.0 + updateProgress() } func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { isSpeaking = false currentUtterance = "" + currentUtteranceIndex += 1 + updateProgress() + articleProgress = 1.0 } func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { isSpeaking = false currentUtterance = "" + articleProgress = 0.0 } func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) { @@ -100,6 +142,17 @@ class TTSManager: NSObject, ObservableObject, AVSpeechSynthesizerDelegate { isSpeaking = true } + func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, willSpeakRangeOfSpeechString characterRange: NSRange, utterance: AVSpeechUtterance) { + let total = utterance.speechString.count + if total > 0 { + let spoken = characterRange.location + characterRange.length + let progress = min(Double(spoken) / Double(total), 1.0) + DispatchQueue.main.async { + self.articleProgress = progress + } + } + } + func isCurrentlySpeaking() -> Bool { return synthesizer.isSpeaking } diff --git a/readeck/Utils/VoiceManager.swift b/readeck/UI/Utils/VoiceManager.swift similarity index 100% rename from readeck/Utils/VoiceManager.swift rename to readeck/UI/Utils/VoiceManager.swift diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index 952339d..9a152e9 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -24,9 +24,6 @@ struct readeckApp: App { .padding() } } - .onOpenURL { url in - handleIncomingURL(url) - } .onAppear { #if DEBUG NFX.sharedInstance().start() @@ -43,30 +40,4 @@ struct readeckApp: App { let settingsRepository = SettingsRepository() hasFinishedSetup = settingsRepository.hasFinishedSetup } - - private func handleIncomingURL(_ url: URL) { - guard url.scheme == "readeck", - url.host == "add-bookmark" else { - return - } - - let components = URLComponents(url: url, resolvingAgainstBaseURL: true) - let queryItems = components?.queryItems - - let urlToAdd = queryItems?.first(where: { $0.name == "url" })?.value - let title = queryItems?.first(where: { $0.name == "title" })?.value - let notes = queryItems?.first(where: { $0.name == "notes" })?.value - - // Öffne AddBookmarkView mit den Daten - // Hier kannst du eine Notification posten oder einen State ändern - NotificationCenter.default.post( - name: NSNotification.Name("AddBookmarkFromShare"), - object: nil, - userInfo: [ - "url": urlToAdd ?? "", - "title": title ?? "", - "notes": notes ?? "" - ] - ) - } } diff --git a/readeck/Utils/SpeechQueue.swift b/readeck/Utils/SpeechQueue.swift deleted file mode 100644 index caae268..0000000 --- a/readeck/Utils/SpeechQueue.swift +++ /dev/null @@ -1,76 +0,0 @@ -import Foundation -import Combine - -class SpeechQueue: ObservableObject { - private var queue: [String] = [] - private var isProcessing = false - private let ttsManager: TTSManager - private let language: String - - static let shared = SpeechQueue() - - @Published var queueItems: [String] = [] - @Published var currentText: String = "" - @Published var hasItems: Bool = false - - var queueCount: Int { - return queueItems.count - } - - var currentItem: String? { - return queueItems.first - } - - private init(ttsManager: TTSManager = .shared, language: String = "de-DE") { - self.ttsManager = ttsManager - self.language = language - } - - func enqueue(_ text: String) { - queue.append(text) - updatePublishedProperties() - processQueue() - } - - func enqueue(contentsOf texts: [String]) { - queue.append(contentsOf: texts) - updatePublishedProperties() - processQueue() - } - - func clear() { - queue.removeAll() - ttsManager.stop() - isProcessing = false - updatePublishedProperties() - } - - private func updatePublishedProperties() { - queueItems = queue - currentText = queue.first ?? "" - hasItems = !queue.isEmpty || ttsManager.isCurrentlySpeaking() - } - - 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.2) { [weak self] in - self?.waitForSpeechToFinish() - } - } - - private func waitForSpeechToFinish() { - if ttsManager.isCurrentlySpeaking() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in - self?.waitForSpeechToFinish() - } - } else { - self.isProcessing = false - self.updatePublishedProperties() - self.processQueue() - } - } -}