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:
Ilyas Hallak 2025-07-14 21:34:39 +02:00
parent 9b89e58115
commit e68959afce
25 changed files with 643 additions and 373 deletions

View File

@ -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" : {

View File

@ -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 ?? ""
) )
} }

View File

@ -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: ""
) )
} }

View 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
}

View File

@ -14,6 +14,6 @@ class AddTextToSpeechQueueUseCase {
} else { } else {
text += bookmarkDetail.description.stripHTML text += bookmarkDetail.description.stripHTML
} }
speechQueue.enqueue(text) speechQueue.enqueue(bookmarkDetail.toSpeechQueueItem(text))
} }
} }

View File

@ -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 {

View File

@ -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

View File

@ -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)
}

View File

@ -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))")

View File

@ -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)
} }
} }

View File

@ -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 {

View 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)
}
}
}

View File

@ -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)
} }
} }
} }

View File

@ -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())
} }

View 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()
}
}

View File

@ -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 {

View File

@ -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()
} }
} }

View 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!")
}
}
}

View File

@ -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
} }

View File

@ -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 ?? ""
]
)
}
} }

View File

@ -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()
}
}
}