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
This commit is contained in:
parent
9b89e58115
commit
e68959afce
@ -17,7 +17,7 @@
|
|||||||
"%lld" : {
|
"%lld" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"%lld Artikel in Queue" : {
|
"%lld Artikel in der Queue" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"%lld min" : {
|
"%lld min" : {
|
||||||
@ -28,6 +28,16 @@
|
|||||||
},
|
},
|
||||||
"%lld." : {
|
"%lld." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"%lld/%lld" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "%1$lld/%2$lld"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"12 min • Today • example.com" : {
|
"12 min • Today • example.com" : {
|
||||||
|
|
||||||
@ -37,9 +47,6 @@
|
|||||||
},
|
},
|
||||||
"Abmelden" : {
|
"Abmelden" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Add Item" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Aktuelle Labels" : {
|
"Aktuelle Labels" : {
|
||||||
|
|
||||||
@ -138,12 +145,18 @@
|
|||||||
},
|
},
|
||||||
"Fertig mit Lesen?" : {
|
"Fertig mit Lesen?" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Fortschritt: %lld%%" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Füge einen neuen Link zu deiner Sammlung hinzu" : {
|
"Füge einen neuen Link zu deiner Sammlung hinzu" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." : {
|
"Geben Sie Ihre Readeck-Server-Details ein, um zu beginnen." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Geschwindigkeit" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"https://example.com" : {
|
"https://example.com" : {
|
||||||
|
|
||||||
@ -159,9 +172,6 @@
|
|||||||
},
|
},
|
||||||
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
|
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
|
||||||
|
|
||||||
},
|
|
||||||
"Item at %@" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Keine Artikel in der Queue" : {
|
"Keine Artikel in der Queue" : {
|
||||||
|
|
||||||
@ -195,6 +205,16 @@
|
|||||||
},
|
},
|
||||||
"Lade Artikel..." : {
|
"Lade Artikel..." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Lese %lld/%lld: " : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Lese %1$lld/%2$lld: "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Leseeinstellungen" : {
|
"Leseeinstellungen" : {
|
||||||
|
|
||||||
@ -244,10 +264,7 @@
|
|||||||
"Schriftgröße" : {
|
"Schriftgröße" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Select a bookmark" : {
|
"Select a bookmark or tag" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Select an item" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Server-Endpunkt" : {
|
"Server-Endpunkt" : {
|
||||||
@ -303,6 +320,9 @@
|
|||||||
},
|
},
|
||||||
"Website" : {
|
"Website" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Weiterhören" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Wiederherstellen" : {
|
"Wiederherstellen" : {
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,5 @@
|
|||||||
import Foundation
|
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 {
|
class BookmarksRepository: PBookmarksRepository {
|
||||||
private var api: PAPI
|
private var api: PAPI
|
||||||
|
|
||||||
@ -40,7 +30,8 @@ class BookmarksRepository: PBookmarksRepository {
|
|||||||
isArchived: bookmarkDetailDto.isArchived,
|
isArchived: bookmarkDetailDto.isArchived,
|
||||||
labels: bookmarkDetailDto.labels,
|
labels: bookmarkDetailDto.labels,
|
||||||
thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "",
|
thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "",
|
||||||
imageUrl: bookmarkDetailDto.resources.image?.src ?? ""
|
imageUrl: bookmarkDetailDto.resources.image?.src ?? "",
|
||||||
|
lang: bookmarkDetailDto.lang ?? ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ struct BookmarkDetail {
|
|||||||
let labels: [String]
|
let labels: [String]
|
||||||
let thumbnailUrl: String
|
let thumbnailUrl: String
|
||||||
let imageUrl: String
|
let imageUrl: String
|
||||||
|
let lang: String
|
||||||
var content: String?
|
var content: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,6 +38,7 @@ extension BookmarkDetail {
|
|||||||
isArchived: false,
|
isArchived: false,
|
||||||
labels: [],
|
labels: [],
|
||||||
thumbnailUrl: "",
|
thumbnailUrl: "",
|
||||||
imageUrl: ""
|
imageUrl: "",
|
||||||
|
lang: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
16
readeck/Domain/Protocols/PBookmarksRepository.swift
Normal file
16
readeck/Domain/Protocols/PBookmarksRepository.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -14,6 +14,6 @@ class AddTextToSpeechQueueUseCase {
|
|||||||
} else {
|
} else {
|
||||||
text += bookmarkDetail.description.stripHTML
|
text += bookmarkDetail.description.stripHTML
|
||||||
}
|
}
|
||||||
speechQueue.enqueue(text)
|
speechQueue.enqueue(bookmarkDetail.toSpeechQueueItem(text))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ struct BookmarkDetailView: View {
|
|||||||
@State private var webViewHeight: CGFloat = 300
|
@State private var webViewHeight: CGFloat = 300
|
||||||
@State private var showingFontSettings = false
|
@State private var showingFontSettings = false
|
||||||
@State private var showingLabelsSheet = false
|
@State private var showingLabelsSheet = false
|
||||||
|
@EnvironmentObject var playerUIState: PlayerUIState
|
||||||
|
|
||||||
private let headerHeight: CGFloat = 320
|
private let headerHeight: CGFloat = 320
|
||||||
|
|
||||||
@ -229,6 +230,7 @@ struct BookmarkDetailView: View {
|
|||||||
metaRow(icon: "speaker.wave.2") {
|
metaRow(icon: "speaker.wave.2") {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
viewModel.addBookmarkToSpeechQueue()
|
viewModel.addBookmarkToSpeechQueue()
|
||||||
|
playerUIState.showPlayer()
|
||||||
}) {
|
}) {
|
||||||
Text("Artikel vorlesen")
|
Text("Artikel vorlesen")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
@ -238,7 +240,6 @@ struct BookmarkDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ViewBuilder für Meta-Infos
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func metaRow(icon: String, text: String) -> some View {
|
private func metaRow(icon: String, text: String) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@ -16,6 +16,7 @@ struct BookmarksView: View {
|
|||||||
let state: BookmarkState
|
let state: BookmarkState
|
||||||
let type: [BookmarkType]
|
let type: [BookmarkType]
|
||||||
@Binding var selectedBookmark: Bookmark?
|
@Binding var selectedBookmark: Bookmark?
|
||||||
|
@EnvironmentObject var playerUIState: PlayerUIState
|
||||||
let tag: String?
|
let tag: String?
|
||||||
|
|
||||||
// MARK: Initializer
|
// MARK: Initializer
|
||||||
|
|||||||
@ -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<Item>
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@ -15,7 +15,6 @@ struct LabelsView: View {
|
|||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(viewModel.labels, id: \.href) { label in
|
ForEach(viewModel.labels, id: \.href) { label in
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
BookmarksView(state: .all, type: [], selectedBookmark: .constant(nil), tag: label.name)
|
BookmarksView(state: .all, type: [], selectedBookmark: .constant(nil), tag: label.name)
|
||||||
.navigationTitle("\(label.name) (\(label.count))")
|
.navigationTitle("\(label.name) (\(label.count))")
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import SwiftUI
|
|||||||
struct PadSidebarView: View {
|
struct PadSidebarView: View {
|
||||||
@State private var selectedTab: SidebarTab = .unread
|
@State private var selectedTab: SidebarTab = .unread
|
||||||
@State private var selectedBookmark: Bookmark?
|
@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]
|
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
|
ForEach(sidebarTabs, id: \.self) { tab in
|
||||||
Button(action: {
|
Button(action: {
|
||||||
selectedTab = tab
|
selectedTab = tab
|
||||||
|
selectedBookmark = nil
|
||||||
|
selectedTag = nil
|
||||||
}) {
|
}) {
|
||||||
Label(tab.label, systemImage: tab.systemImage)
|
Label(tab.label, systemImage: tab.systemImage)
|
||||||
.foregroundColor(selectedTab == tab ? .accentColor : .primary)
|
.foregroundColor(selectedTab == tab ? .accentColor : .primary)
|
||||||
@ -31,16 +35,6 @@ struct PadSidebarView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
.listRowBackground(Color(R.color.menu_sidebar_bg))
|
.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))
|
.listRowBackground(Color(R.color.menu_sidebar_bg))
|
||||||
@ -49,7 +43,6 @@ struct PadSidebarView: View {
|
|||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.safeAreaInset(edge: .bottom, alignment: .center) {
|
.safeAreaInset(edge: .bottom, alignment: .center) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Divider()
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
selectedTab = .settings
|
selectedTab = .settings
|
||||||
}) {
|
}) {
|
||||||
@ -60,11 +53,14 @@ struct PadSidebarView: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.listRowBackground(selectedTab == .settings ? Color.accentColor.opacity(0.15) : Color(R.color.menu_sidebar_bg))
|
.listRowBackground(selectedTab == .settings ? Color.accentColor.opacity(0.15) : Color(R.color.menu_sidebar_bg))
|
||||||
|
PlayerQueueResumeButton()
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.background(Color(R.color.menu_sidebar_bg))
|
.background(Color(R.color.menu_sidebar_bg))
|
||||||
}
|
}
|
||||||
} content: {
|
} content: {
|
||||||
|
GlobalPlayerContainerView {
|
||||||
Group {
|
Group {
|
||||||
switch selectedTab {
|
switch selectedTab {
|
||||||
case .search:
|
case .search:
|
||||||
@ -90,13 +86,12 @@ struct PadSidebarView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(selectedTab.label)
|
.navigationTitle(selectedTab.label)
|
||||||
|
}
|
||||||
|
|
||||||
} detail: {
|
} detail: {
|
||||||
if let bookmark = selectedBookmark, selectedTab != .settings {
|
if let bookmark = selectedBookmark, selectedTab != .settings {
|
||||||
BookmarkDetailView(bookmarkId: bookmark.id)
|
BookmarkDetailView(bookmarkId: bookmark.id)
|
||||||
} else {
|
} else {
|
||||||
Text(selectedTab == .settings ? "" : "Select a bookmark")
|
Text(selectedTab == .settings ? "" : "Select a bookmark or tag")
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,7 @@ struct PhoneTabView: View {
|
|||||||
tabView(for: selectedTab)
|
tabView(for: selectedTab)
|
||||||
.navigationTitle(selectedTab.label)
|
.navigationTitle(selectedTab.label)
|
||||||
} else {
|
} else {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
|
List(moreTabs, id: \.self, selection: $selectedMoreTab) { tab in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
tabView(for: tab)
|
tabView(for: tab)
|
||||||
@ -44,6 +45,10 @@ struct PhoneTabView: View {
|
|||||||
.navigationTitle("Mehr")
|
.navigationTitle("Mehr")
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
.background(Color(R.color.bookmark_list_bg))
|
.background(Color(R.color.bookmark_list_bg))
|
||||||
|
|
||||||
|
PlayerQueueResumeButton()
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tabItem {
|
.tabItem {
|
||||||
|
|||||||
51
readeck/UI/Menu/PlayerQueueResumeButton.swift
Normal file
51
readeck/UI/Menu/PlayerQueueResumeButton.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import Foundation
|
|||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
@State private var selectedTab: SidebarTab = .unread
|
@State private var selectedTab: SidebarTab = .unread
|
||||||
@State var selectedBookmark: Bookmark?
|
@State var selectedBookmark: Bookmark?
|
||||||
|
@StateObject private var playerUIState = PlayerUIState()
|
||||||
|
|
||||||
// sizeClass
|
// sizeClass
|
||||||
@Environment(\.horizontalSizeClass)
|
@Environment(\.horizontalSizeClass)
|
||||||
@ -15,8 +16,10 @@ struct MainTabView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
if UIDevice.isPhone {
|
if UIDevice.isPhone {
|
||||||
PhoneTabView()
|
PhoneTabView()
|
||||||
|
.environmentObject(playerUIState)
|
||||||
} else {
|
} else {
|
||||||
PadSidebarView()
|
PadSidebarView()
|
||||||
|
.environmentObject(playerUIState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
struct GlobalPlayerContainerView<Content: View>: View {
|
struct GlobalPlayerContainerView<Content: View>: View {
|
||||||
let content: Content
|
let content: Content
|
||||||
@StateObject private var viewModel = SpeechPlayerViewModel()
|
@StateObject private var viewModel = SpeechPlayerViewModel()
|
||||||
|
@EnvironmentObject var playerUIState: PlayerUIState
|
||||||
|
|
||||||
init(@ViewBuilder content: () -> Content) {
|
init(@ViewBuilder content: () -> Content) {
|
||||||
self.content = content()
|
self.content = content()
|
||||||
@ -13,13 +14,12 @@ struct GlobalPlayerContainerView<Content: View>: View {
|
|||||||
content
|
content
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
if viewModel.hasItems {
|
if viewModel.hasItems && playerUIState.isPlayerVisible {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
SpeechPlayerView()
|
SpeechPlayerView(onClose: { playerUIState.hidePlayer() })
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||||
|
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(.clear)
|
.fill(.clear)
|
||||||
.frame(height: 49)
|
.frame(height: 49)
|
||||||
@ -36,4 +36,5 @@ struct GlobalPlayerContainerView<Content: View>: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color(.systemBackground))
|
.background(Color(.systemBackground))
|
||||||
}
|
}
|
||||||
|
.environmentObject(PlayerUIState())
|
||||||
}
|
}
|
||||||
|
|||||||
18
readeck/UI/SpeechPlayer/PlayerUIState.swift
Normal file
18
readeck/UI/SpeechPlayer/PlayerUIState.swift
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,16 +4,17 @@ struct SpeechPlayerView: View {
|
|||||||
@State var viewModel = SpeechPlayerViewModel()
|
@State var viewModel = SpeechPlayerViewModel()
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
@State private var dragOffset: CGFloat = 0
|
@State private var dragOffset: CGFloat = 0
|
||||||
|
var onClose: (() -> Void)? = nil
|
||||||
|
|
||||||
private let minHeight: CGFloat = 60
|
private let minHeight: CGFloat = 60
|
||||||
private let maxHeight: CGFloat = 300
|
private let maxHeight: CGFloat = UIScreen.main.bounds.height / 2
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if isExpanded {
|
if isExpanded {
|
||||||
expandedView
|
ExpandedPlayerView(viewModel: viewModel, isExpanded: $isExpanded, onClose: onClose)
|
||||||
} else {
|
} else {
|
||||||
collapsedView
|
CollapsedPlayerBar(viewModel: viewModel, isExpanded: $isExpanded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: isExpanded ? maxHeight : minHeight)
|
.frame(height: isExpanded ? maxHeight : minHeight)
|
||||||
@ -38,10 +39,14 @@ struct SpeechPlayerView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var collapsedView: some View {
|
private struct CollapsedPlayerBar: View {
|
||||||
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
||||||
|
@Binding var isExpanded: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
// Play/Pause Button
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if viewModel.isSpeaking {
|
if viewModel.isSpeaking {
|
||||||
viewModel.pause()
|
viewModel.pause()
|
||||||
@ -53,43 +58,37 @@ struct SpeechPlayerView: View {
|
|||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Current Text
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(viewModel.currentText.isEmpty ? "Keine Wiedergabe" : viewModel.currentText)
|
Text(viewModel.currentText.isEmpty ? "Keine Wiedergabe" : viewModel.currentText)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.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 {
|
if viewModel.queueCount > 0 {
|
||||||
Text("\(viewModel.queueCount) Artikel in Queue")
|
HStack(spacing: 4) {
|
||||||
.font(.caption)
|
Image(systemName: "text.line.first.and.arrowtriangle.forward")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("\(viewModel.currentUtteranceIndex + 1)/\(viewModel.queueCount)")
|
||||||
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}.onTapGesture {
|
}.onTapGesture {
|
||||||
withAnimation(.spring()) {
|
withAnimation(.spring()) { isExpanded.toggle() }
|
||||||
isExpanded.toggle()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button(action: { viewModel.stop() }) {
|
||||||
// Stop Button
|
|
||||||
Button(action: {
|
|
||||||
viewModel.stop()
|
|
||||||
}) {
|
|
||||||
Image(systemName: "stop.fill")
|
Image(systemName: "stop.fill")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
Button(action: { withAnimation(.spring()) { isExpanded.toggle() } }) {
|
||||||
// Expand Button
|
|
||||||
Button(action: {
|
|
||||||
withAnimation(.spring()) {
|
|
||||||
isExpanded.toggle()
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Image(systemName: "chevron.up")
|
Image(systemName: "chevron.up")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@ -98,22 +97,28 @@ struct SpeechPlayerView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var expandedView: some View {
|
private struct ExpandedPlayerView: View {
|
||||||
|
@ObservedObject var viewModel: SpeechPlayerViewModel
|
||||||
|
@Binding var isExpanded: Bool
|
||||||
|
var onClose: (() -> Void)? = nil
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
// Header
|
// Header
|
||||||
HStack {
|
HStack {
|
||||||
|
Button(action: { onClose?() }) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
Text("Vorlese-Queue")
|
Text("Vorlese-Queue")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button(action: { withAnimation(.spring()) { isExpanded = false } }) {
|
||||||
Button(action: {
|
|
||||||
withAnimation(.spring()) {
|
|
||||||
isExpanded = false
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Image(systemName: "chevron.down")
|
Image(systemName: "chevron.down")
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@ -121,8 +126,53 @@ struct SpeechPlayerView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 16)
|
.padding(.top, 16)
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
// Controls
|
PlayerControls(viewModel: viewModel)
|
||||||
|
PlayerVolume(viewModel: viewModel)
|
||||||
|
|
||||||
|
if viewModel.queueCount > 0 {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "text.line.first.and.arrowtriangle.forward")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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) {
|
HStack(spacing: 24) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if viewModel.isSpeaking {
|
if viewModel.isSpeaking {
|
||||||
@ -135,17 +185,80 @@ struct SpeechPlayerView: View {
|
|||||||
.font(.title)
|
.font(.title)
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
}
|
}
|
||||||
|
Button(action: { viewModel.stop() }) {
|
||||||
Button(action: {
|
|
||||||
viewModel.stop()
|
|
||||||
}) {
|
|
||||||
Image(systemName: "stop.fill")
|
Image(systemName: "stop.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.foregroundColor(.secondary)
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Queue List
|
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 {
|
if viewModel.queueCount == 0 {
|
||||||
Text("Keine Artikel in der Queue")
|
Text("Keine Artikel in der Queue")
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
@ -160,12 +273,10 @@ struct SpeechPlayerView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.frame(width: 20, alignment: .leading)
|
.frame(width: 20, alignment: .leading)
|
||||||
|
Text(item.title)
|
||||||
Text(item)
|
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
@ -177,10 +288,14 @@ struct SpeechPlayerView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Array safe access helper
|
||||||
|
fileprivate extension Array {
|
||||||
|
subscript(safe index: Int) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
|
|||||||
@ -9,8 +9,14 @@ class SpeechPlayerViewModel: ObservableObject {
|
|||||||
@Published var isSpeaking: Bool = false
|
@Published var isSpeaking: Bool = false
|
||||||
@Published var currentText: String = ""
|
@Published var currentText: String = ""
|
||||||
@Published var queueCount: Int = 0
|
@Published var queueCount: Int = 0
|
||||||
@Published var queueItems: [String] = []
|
@Published var queueItems: [SpeechQueueItem] = []
|
||||||
@Published var hasItems: Bool = false
|
@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) {
|
init(ttsManager: TTSManager = .shared, speechQueue: SpeechQueue = .shared) {
|
||||||
self.ttsManager = ttsManager
|
self.ttsManager = ttsManager
|
||||||
@ -41,6 +47,39 @@ class SpeechPlayerViewModel: ObservableObject {
|
|||||||
speechQueue.$hasItems
|
speechQueue.$hasItems
|
||||||
.assign(to: \.hasItems, on: self)
|
.assign(to: \.hasItems, on: self)
|
||||||
.store(in: &cancellables)
|
.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() {
|
func pause() {
|
||||||
@ -52,6 +91,6 @@ class SpeechPlayerViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
speechQueue.clear()
|
ttsManager.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
153
readeck/UI/Utils/SpeechQueue.swift
Normal file
153
readeck/UI/Utils/SpeechQueue.swift
Normal file
@ -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!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,11 +10,18 @@ class TTSManager: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
|
|||||||
|
|
||||||
@Published var isSpeaking = false
|
@Published var isSpeaking = false
|
||||||
@Published var currentUtterance = ""
|
@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() {
|
override private init() {
|
||||||
super.init()
|
super.init()
|
||||||
synthesizer.delegate = self
|
synthesizer.delegate = self
|
||||||
configureAudioSession()
|
configureAudioSession()
|
||||||
|
loadSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureAudioSession() {
|
private func configureAudioSession() {
|
||||||
@ -22,16 +29,13 @@ class TTSManager: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
|
|||||||
let audioSession = AVAudioSession.sharedInstance()
|
let audioSession = AVAudioSession.sharedInstance()
|
||||||
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
|
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
|
||||||
try audioSession.setActive(true)
|
try audioSession.setActive(true)
|
||||||
|
|
||||||
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
|
try audioSession.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers, .duckOthers, .allowBluetooth, .allowBluetoothA2DP])
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(handleAppDidEnterBackground),
|
selector: #selector(handleAppDidEnterBackground),
|
||||||
name: UIApplication.didEnterBackgroundNotification,
|
name: UIApplication.didEnterBackgroundNotification,
|
||||||
object: nil
|
object: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(handleAppWillEnterForeground),
|
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 }
|
guard !text.isEmpty else { return }
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.isSpeaking = true
|
self.isSpeaking = true
|
||||||
self.currentUtterance = text
|
self.currentUtterance = text
|
||||||
|
self.currentUtteranceIndex = utteranceIndex
|
||||||
|
self.totalUtterances = totalUtterances
|
||||||
|
self.updateProgress()
|
||||||
|
self.articleProgress = 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
if synthesizer.isSpeaking {
|
if synthesizer.isSpeaking {
|
||||||
synthesizer.stopSpeaking(at: .immediate)
|
synthesizer.stopSpeaking(at: .immediate)
|
||||||
}
|
}
|
||||||
|
|
||||||
let utterance = AVSpeechUtterance(string: text)
|
let utterance = AVSpeechUtterance(string: text)
|
||||||
|
|
||||||
utterance.voice = voiceManager.getVoice(for: language)
|
utterance.voice = voiceManager.getVoice(for: language)
|
||||||
|
utterance.rate = rate
|
||||||
utterance.rate = 0.5
|
|
||||||
utterance.pitchMultiplier = 1.0
|
utterance.pitchMultiplier = 1.0
|
||||||
utterance.volume = 1.0
|
utterance.volume = volume
|
||||||
|
|
||||||
synthesizer.speak(utterance)
|
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() {
|
func pause() {
|
||||||
synthesizer.pauseSpeaking(at: .immediate)
|
synthesizer.pauseSpeaking(at: .immediate)
|
||||||
isSpeaking = false
|
isSpeaking = false
|
||||||
@ -80,16 +116,22 @@ class TTSManager: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
|
|||||||
synthesizer.stopSpeaking(at: .immediate)
|
synthesizer.stopSpeaking(at: .immediate)
|
||||||
isSpeaking = false
|
isSpeaking = false
|
||||||
currentUtterance = ""
|
currentUtterance = ""
|
||||||
|
articleProgress = 0.0
|
||||||
|
updateProgress()
|
||||||
}
|
}
|
||||||
|
|
||||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
|
||||||
isSpeaking = false
|
isSpeaking = false
|
||||||
currentUtterance = ""
|
currentUtterance = ""
|
||||||
|
currentUtteranceIndex += 1
|
||||||
|
updateProgress()
|
||||||
|
articleProgress = 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
|
||||||
isSpeaking = false
|
isSpeaking = false
|
||||||
currentUtterance = ""
|
currentUtterance = ""
|
||||||
|
articleProgress = 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
|
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didPause utterance: AVSpeechUtterance) {
|
||||||
@ -100,6 +142,17 @@ class TTSManager: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
|
|||||||
isSpeaking = true
|
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 {
|
func isCurrentlySpeaking() -> Bool {
|
||||||
return synthesizer.isSpeaking
|
return synthesizer.isSpeaking
|
||||||
}
|
}
|
||||||
@ -24,9 +24,6 @@ struct readeckApp: App {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
|
||||||
handleIncomingURL(url)
|
|
||||||
}
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
NFX.sharedInstance().start()
|
NFX.sharedInstance().start()
|
||||||
@ -43,30 +40,4 @@ struct readeckApp: App {
|
|||||||
let settingsRepository = SettingsRepository()
|
let settingsRepository = SettingsRepository()
|
||||||
hasFinishedSetup = settingsRepository.hasFinishedSetup
|
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 ?? ""
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user