Merge pull request #1396 from omnivore-app/feat/ultra-voices
iOS work on realistic voices
This commit is contained in:
@ -1,12 +1,13 @@
|
||||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
public class ShareExtensionViewModel: ObservableObject {
|
||||
@Published public var status: ShareExtensionStatus = .processing
|
||||
@Published public var title: String?
|
||||
@Published public var title: String = ""
|
||||
@Published public var url: String?
|
||||
@Published public var iconURL: String?
|
||||
@Published public var linkedItem: LinkedItem?
|
||||
@ -42,6 +43,22 @@ public class ShareExtensionViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) {
|
||||
dataService.archiveLink(objectID: objectID, archived: archived)
|
||||
}
|
||||
|
||||
func removeLink(dataService: DataService, objectID: NSManagedObjectID) {
|
||||
dataService.removeLink(objectID: objectID)
|
||||
}
|
||||
|
||||
func submitTitleEdit(dataService: DataService, itemID: String, title: String, description: String) {
|
||||
dataService.updateLinkedItemTitleAndDescription(
|
||||
itemID: itemID,
|
||||
title: title,
|
||||
description: description
|
||||
)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func queueSaveOperation(_ payload: PageScrapePayload) {
|
||||
ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in
|
||||
@ -72,7 +89,7 @@ public class ShareExtensionViewModel: ObservableObject {
|
||||
|
||||
switch payload.contentType {
|
||||
case let .html(html: _, title: title, iconURL: iconURL):
|
||||
self.title = title
|
||||
self.title = title ?? ""
|
||||
self.iconURL = iconURL
|
||||
self.url = hostname
|
||||
case .none:
|
||||
|
||||
@ -6,10 +6,22 @@ import Views
|
||||
|
||||
public struct ShareExtensionView: View {
|
||||
let extensionContext: NSExtensionContext?
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@StateObject var labelsViewModel = LabelsViewModel()
|
||||
@StateObject private var viewModel = ShareExtensionViewModel()
|
||||
|
||||
@State var reminderTime: ReminderTime?
|
||||
@State var hideUntilReminded = false
|
||||
@State var editingTitle = false
|
||||
@State var editingLabels = false
|
||||
@State var previousLabels: [LinkedItemLabel]?
|
||||
@State var messageText: String?
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case titleEditor
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: FocusField?
|
||||
|
||||
private func handleReminderTimeSelection(_ selectedTime: ReminderTime) {
|
||||
if selectedTime == reminderTime {
|
||||
@ -134,61 +146,331 @@ public struct ShareExtensionView: View {
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text(titleText)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.font(Font.system(size: 17, weight: .semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 23)
|
||||
.padding(.bottom, 12)
|
||||
var isSynced: Bool {
|
||||
switch viewModel.status {
|
||||
case .synced:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle()
|
||||
.foregroundColor(.appGrayText)
|
||||
.frame(maxWidth: .infinity, maxHeight: 1)
|
||||
.opacity(0.06)
|
||||
.padding(.top, 0)
|
||||
.padding(.bottom, 18)
|
||||
var titleBar: some View {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
previewCard
|
||||
.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||||
Image(systemName: "checkmark.circle")
|
||||
.frame(width: 15, height: 15)
|
||||
.foregroundColor(.appGreenSuccess)
|
||||
.opacity(isSynced ? 1.0 : 0.0)
|
||||
|
||||
if let item = viewModel.linkedItem {
|
||||
ApplyLabelsListView(linkedItem: item)
|
||||
Text(messageText ?? titleText)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(isSynced ? .appGreenSuccess : .appGrayText)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
public var titleBox: some View {
|
||||
VStack(alignment: .trailing) {
|
||||
Button(action: {}, label: {
|
||||
Text("Edit")
|
||||
.font(.appFootnote)
|
||||
.padding(.trailing, 8)
|
||||
.onTapGesture {
|
||||
editingTitle = true
|
||||
}
|
||||
})
|
||||
.disabled(editingTitle)
|
||||
.opacity(editingTitle ? 0.0 : 1.0)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
if !editingTitle {
|
||||
Text(self.viewModel.title)
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(self.viewModel.url ?? "")
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(.appGrayText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: 60)
|
||||
.padding()
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.appGrayBorder, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var labelsSection: some View {
|
||||
HStack {
|
||||
if !editingLabels {
|
||||
ZStack {
|
||||
Circle()
|
||||
.foregroundColor(Color.blue)
|
||||
.frame(width: 34, height: 34)
|
||||
|
||||
Image(systemName: "tag")
|
||||
.font(.appCallout)
|
||||
.frame(width: 34, height: 34)
|
||||
}
|
||||
.padding(.trailing, 8)
|
||||
|
||||
VStack {
|
||||
Text("Labels")
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(Color.appGrayTextContrast)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
let labelCount = labelsViewModel.selectedLabels.count
|
||||
Text(labelCount > 0 ?
|
||||
"\(labelCount) label\(labelCount > 1 ? "s" : "") selected"
|
||||
: "Add labels to your saved link")
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(Color.appGrayText)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.appCallout)
|
||||
} else {
|
||||
ScrollView {
|
||||
LabelsMasonaryView(labels: labelsViewModel.labels,
|
||||
selectedLabels: labelsViewModel.selectedLabels,
|
||||
onLabelTap: onLabelTap)
|
||||
}.background(Color.appButtonBackground)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: self.editingLabels ? .infinity : 60)
|
||||
.background(Color.appButtonBackground)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
func onLabelTap(label: LinkedItemLabel, textChip _: TextChip) {
|
||||
if let selectedIndex = labelsViewModel.selectedLabels.firstIndex(of: label) {
|
||||
labelsViewModel.selectedLabels.remove(at: selectedIndex)
|
||||
} else {
|
||||
labelsViewModel.selectedLabels.append(label)
|
||||
}
|
||||
|
||||
if let linkedItem = viewModel.linkedItem {
|
||||
labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID, dataService: viewModel.services.dataService)
|
||||
}
|
||||
}
|
||||
|
||||
var primaryButtons: some View {
|
||||
HStack {
|
||||
Button(
|
||||
action: { viewModel.handleReadNowAction(extensionContext: extensionContext) },
|
||||
label: {
|
||||
Label("Read Now", systemImage: "book")
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.background(Color.appButtonBackground)
|
||||
.frame(height: 52)
|
||||
.cornerRadius(8)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Button(
|
||||
action: {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
},
|
||||
label: {
|
||||
Label("Read Later", systemImage: "text.book.closed.fill")
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
)
|
||||
.foregroundColor(.black)
|
||||
.background(Color.appBackground)
|
||||
.frame(height: 52)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
}
|
||||
|
||||
var moreActionsMenu: some View {
|
||||
Menu {
|
||||
Button(
|
||||
action: {},
|
||||
label: {
|
||||
Button(action: {}, label: { Label("Dismiss", systemImage: "arrow.down.to.line") })
|
||||
}
|
||||
)
|
||||
Button(action: {
|
||||
if let linkedItem = self.viewModel.linkedItem {
|
||||
self.viewModel.setLinkArchived(dataService: self.viewModel.services.dataService,
|
||||
objectID: linkedItem.objectID,
|
||||
archived: true)
|
||||
messageText = "Link Archived"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}, label: {
|
||||
Label(
|
||||
"Archive",
|
||||
systemImage: "archivebox"
|
||||
)
|
||||
})
|
||||
Button(
|
||||
action: {
|
||||
if let linkedItem = self.viewModel.linkedItem {
|
||||
self.viewModel.removeLink(dataService: self.viewModel.services.dataService, objectID: linkedItem.objectID)
|
||||
messageText = "Link Removed"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
},
|
||||
label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
)
|
||||
} label: {
|
||||
Text("More Actions")
|
||||
.font(.appFootnote)
|
||||
.foregroundColor(Color.blue)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(8)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .center) {
|
||||
Capsule()
|
||||
.fill(.gray)
|
||||
.frame(width: 60, height: 4)
|
||||
.padding(.top, 10)
|
||||
|
||||
if !editingLabels, !editingTitle {
|
||||
titleBar
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
} else {
|
||||
ZStack {
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
if editingLabels {
|
||||
if let linkedItem = self.viewModel.linkedItem {
|
||||
self.labelsViewModel.selectedLabels = previousLabels ?? []
|
||||
self.labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID,
|
||||
dataService: self.viewModel.services.dataService)
|
||||
}
|
||||
}
|
||||
editingTitle = false
|
||||
editingLabels = false
|
||||
}
|
||||
}, label: { Text("Cancel") })
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Text(editingTitle ? "Edit Title" : "Labels").bold()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
Button(action: {
|
||||
withAnimation {
|
||||
editingTitle = false
|
||||
editingLabels = false
|
||||
|
||||
if editingTitle {
|
||||
if let linkedItem = self.viewModel.linkedItem {
|
||||
viewModel.submitTitleEdit(dataService: self.viewModel.services.dataService,
|
||||
itemID: linkedItem.unwrappedID,
|
||||
title: self.viewModel.title,
|
||||
description: linkedItem.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, label: { Text("Done").bold() })
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
.padding(8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
if !editingLabels, !editingTitle {
|
||||
titleBox
|
||||
}
|
||||
|
||||
if editingTitle {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .center, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
TextEditor(text: $viewModel.title)
|
||||
.lineSpacing(6)
|
||||
.accentColor(.appGraySolid)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.font(.appSubheadline)
|
||||
.padding(8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground))
|
||||
)
|
||||
.frame(height: 100)
|
||||
.focused($focusedField, equals: .titleEditor)
|
||||
.task {
|
||||
self.focusedField = .titleEditor
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button(
|
||||
action: { viewModel.handleReadNowAction(extensionContext: extensionContext) },
|
||||
label: { Text("Read Now").frame(maxWidth: .infinity) }
|
||||
)
|
||||
.buttonStyle(RoundedRectButtonStyle())
|
||||
|
||||
Button(
|
||||
action: {
|
||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
},
|
||||
label: {
|
||||
Text("Read Later")
|
||||
.frame(maxWidth: .infinity)
|
||||
if !editingTitle {
|
||||
labelsSection
|
||||
.padding(.top, 12)
|
||||
.onTapGesture {
|
||||
withAnimation {
|
||||
previousLabels = self.labelsViewModel.selectedLabels
|
||||
editingLabels = true
|
||||
}
|
||||
}
|
||||
)
|
||||
.buttonStyle(RoundedRectButtonStyle())
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom)
|
||||
|
||||
Spacer()
|
||||
|
||||
if !editingLabels, !editingTitle {
|
||||
Divider()
|
||||
.padding(.bottom, 20)
|
||||
|
||||
primaryButtons
|
||||
|
||||
moreActionsMenu
|
||||
}
|
||||
}
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity,
|
||||
alignment: .topLeading
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.onAppear {
|
||||
viewModel.savePage(extensionContext: extensionContext)
|
||||
}
|
||||
.environmentObject(viewModel.services.dataService)
|
||||
.task {
|
||||
await labelsViewModel.loadLabelsFromStore(dataService: viewModel.services.dataService)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,10 +14,6 @@
|
||||
@State var showVoiceSheet = false
|
||||
@State var tabIndex: Int = 0
|
||||
|
||||
var isPresented: Bool {
|
||||
audioController.itemAudioProperties != nil && audioController.state != .stopped
|
||||
}
|
||||
|
||||
var playPauseButtonImage: String {
|
||||
switch audioController.state {
|
||||
case .playing:
|
||||
@ -146,7 +142,7 @@
|
||||
+
|
||||
Text(audioController.unreadText)
|
||||
.font(.textToSpeechRead.leading(.loose))
|
||||
.foregroundColor(Color.appGrayText)
|
||||
.foregroundColor(audioController.useUltraRealisticVoices ? Color.appGrayTextContrast : Color.appGrayText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -345,7 +341,7 @@
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
if let itemAudioProperties = self.audioController.itemAudioProperties, isPresented {
|
||||
if let itemAudioProperties = self.audioController.itemAudioProperties {
|
||||
playerContent(itemAudioProperties)
|
||||
.tint(.appGrayTextContrast)
|
||||
} else {
|
||||
|
||||
@ -20,10 +20,6 @@
|
||||
self.presentingView = AnyView(presentingView)
|
||||
}
|
||||
|
||||
var isPresented: Bool {
|
||||
audioController.itemAudioProperties != nil && audioController.state != .stopped
|
||||
}
|
||||
|
||||
var playPauseButtonImage: String {
|
||||
switch audioController.state {
|
||||
case .playing:
|
||||
@ -158,7 +154,7 @@
|
||||
public var body: some View {
|
||||
ZStack(alignment: .center) {
|
||||
presentingView
|
||||
if let itemAudioProperties = self.audioController.itemAudioProperties, isPresented {
|
||||
if let itemAudioProperties = self.audioController.itemAudioProperties {
|
||||
ZStack(alignment: .bottom) {
|
||||
Color.systemBackground.edgesIgnoringSafeArea(.bottom)
|
||||
.frame(height: expanded ? 0 : 88, alignment: .bottom)
|
||||
@ -172,6 +168,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}.alert("There was an error playing back your audio.",
|
||||
isPresented: $audioController.playbackError) {
|
||||
Button("Dismiss", role: .none) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -215,56 +215,118 @@ import Views
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
var filtersHeader: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
if viewModel.searchTerm.count > 0 {
|
||||
TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) {
|
||||
viewModel.searchTerm = ""
|
||||
GeometryReader { reader in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
if viewModel.searchTerm.count > 0 {
|
||||
TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) {
|
||||
viewModel.searchTerm = ""
|
||||
}.frame(maxWidth: reader.size.width * 0.66)
|
||||
} else {
|
||||
Menu(
|
||||
content: {
|
||||
ForEach(LinkedItemFilter.allCases, id: \.self) { filter in
|
||||
Button(filter.displayName, action: { viewModel.appliedFilter = filter.rawValue })
|
||||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: LinkedItemFilter(rawValue: viewModel.appliedFilter)?.displayName ?? "Filter"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Menu(
|
||||
content: {
|
||||
ForEach(LinkedItemFilter.allCases, id: \.self) { filter in
|
||||
Button(filter.displayName, action: { viewModel.appliedFilter = filter.rawValue })
|
||||
ForEach(LinkedItemSort.allCases, id: \.self) { sort in
|
||||
Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue })
|
||||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: LinkedItemFilter(rawValue: viewModel.appliedFilter)?.displayName ?? "Filter"
|
||||
title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
Menu(
|
||||
content: {
|
||||
ForEach(LinkedItemSort.allCases, id: \.self) { sort in
|
||||
Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue })
|
||||
TextChipButton.makeAddLabelButton {
|
||||
viewModel.showLabelsSheet = true
|
||||
}
|
||||
ForEach(viewModel.selectedLabels, id: \.self) { label in
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) {
|
||||
viewModel.selectedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
ForEach(viewModel.negatedLabels, id: \.self) { label in
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) {
|
||||
viewModel.negatedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(0)
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
func menuItems(for item: LinkedItem) -> some View {
|
||||
Group {
|
||||
if (item.highlights?.count ?? 0) > 0 {
|
||||
Button(
|
||||
action: { viewModel.itemForHighlightsView = item },
|
||||
label: { Label("View Highlights & Notes", systemImage: "highlighter") }
|
||||
)
|
||||
}
|
||||
Button(
|
||||
action: { viewModel.itemUnderTitleEdit = item },
|
||||
label: { Label("Edit Title/Description", systemImage: "textbox") }
|
||||
)
|
||||
Button(
|
||||
action: { viewModel.itemUnderLabelEdit = item },
|
||||
label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(action: {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(
|
||||
dataService: dataService,
|
||||
objectID: item.objectID,
|
||||
archived: !item.isArchived
|
||||
)
|
||||
}
|
||||
}, label: {
|
||||
Label(
|
||||
item.isArchived ? "Unarchive" : "Archive",
|
||||
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
)
|
||||
})
|
||||
Button(
|
||||
action: {
|
||||
itemToRemove = item
|
||||
confirmationShown = true
|
||||
},
|
||||
label: {
|
||||
Label("Remove Item", systemImage: "trash")
|
||||
}
|
||||
).tint(.red)
|
||||
if FeatureFlag.enableSnooze {
|
||||
Button {
|
||||
viewModel.itemToSnoozeID = item.id
|
||||
viewModel.snoozePresented = true
|
||||
} label: {
|
||||
Label { Text("Snooze") } icon: { Image.moon }
|
||||
}
|
||||
}
|
||||
if let author = item.author {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.searchTerm = "author:\"\(author)\""
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort"
|
||||
)
|
||||
Label(String("More by \(author)"), systemImage: "person")
|
||||
}
|
||||
)
|
||||
TextChipButton.makeAddLabelButton {
|
||||
viewModel.showLabelsSheet = true
|
||||
}
|
||||
ForEach(viewModel.selectedLabels, id: \.self) { label in
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) {
|
||||
viewModel.selectedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
ForEach(viewModel.negatedLabels, id: \.self) { label in
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) {
|
||||
viewModel.negatedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(0)
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -292,59 +354,7 @@ import Views
|
||||
viewModel: viewModel
|
||||
)
|
||||
.contextMenu {
|
||||
Button(
|
||||
action: { viewModel.itemForHighlightsView = item },
|
||||
label: { Label("View Highlights & Notes", systemImage: "highlighter") }
|
||||
)
|
||||
Button(
|
||||
action: { viewModel.itemUnderTitleEdit = item },
|
||||
label: { Label("Edit Title/Description", systemImage: "textbox") }
|
||||
)
|
||||
Button(
|
||||
action: { viewModel.itemUnderLabelEdit = item },
|
||||
label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(action: {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(
|
||||
dataService: dataService,
|
||||
objectID: item.objectID,
|
||||
archived: !item.isArchived
|
||||
)
|
||||
}
|
||||
}, label: {
|
||||
Label(
|
||||
item.isArchived ? "Unarchive" : "Archive",
|
||||
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
)
|
||||
})
|
||||
Button(
|
||||
action: {
|
||||
itemToRemove = item
|
||||
confirmationShown = true
|
||||
},
|
||||
label: {
|
||||
Label("Remove Item", systemImage: "trash")
|
||||
}
|
||||
).tint(.red)
|
||||
if FeatureFlag.enableSnooze {
|
||||
Button {
|
||||
viewModel.itemToSnoozeID = item.id
|
||||
viewModel.snoozePresented = true
|
||||
} label: {
|
||||
Label { Text("Snooze") } icon: { Image.moon }
|
||||
}
|
||||
}
|
||||
if let author = item.author {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.searchTerm = "author:\"\(author)\""
|
||||
},
|
||||
label: {
|
||||
Label(String("More by \(author)"), systemImage: "person")
|
||||
}
|
||||
)
|
||||
}
|
||||
menuItems(for: item)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !item.isArchived {
|
||||
@ -428,8 +438,6 @@ import Views
|
||||
viewModel.itemUnderLabelEdit = item
|
||||
case .editTitle:
|
||||
viewModel.itemUnderTitleEdit = item
|
||||
case .downloadAudio:
|
||||
viewModel.downloadAudio(audioController: audioController, item: item)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -50,7 +50,7 @@ import Views
|
||||
)
|
||||
Button(
|
||||
action: { viewModel.itemUnderLabelEdit = item },
|
||||
label: { Label("Edit Labels", systemImage: "tag") }
|
||||
label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(action: {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
|
||||
@ -175,14 +175,6 @@ import Views
|
||||
showLoadingBar = false
|
||||
}
|
||||
|
||||
func downloadAudio(audioController: AudioController, item: LinkedItem) {
|
||||
Snackbar.show(message: "Downloading Offline Audio")
|
||||
Task {
|
||||
let downloaded = await audioController.downloadForOffline(itemID: item.unwrappedID)
|
||||
Snackbar.show(message: downloaded ? "Audio file downloaded" : "Error downloading audio")
|
||||
}
|
||||
}
|
||||
|
||||
private var fetchRequest: NSFetchRequest<Models.LinkedItem> {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
//
|
||||
// LabelsMasonaryView.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 11/9/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
import Models
|
||||
import Views
|
||||
|
||||
struct LabelsMasonaryView: View {
|
||||
// var allLabels: [LinkedItemLabel]
|
||||
// var selectedLabels: [LinkedItemLabel]
|
||||
var onLabelTap: (LinkedItemLabel, TextChip) -> Void
|
||||
|
||||
var iteration = UUID().uuidString
|
||||
|
||||
@State private var totalHeight = CGFloat.zero
|
||||
private var labelItems: [(label: LinkedItemLabel, selected: Bool)]
|
||||
|
||||
init(labels allLabels: [LinkedItemLabel],
|
||||
selectedLabels: [LinkedItemLabel],
|
||||
onLabelTap: @escaping (LinkedItemLabel, TextChip) -> Void)
|
||||
{
|
||||
self.onLabelTap = onLabelTap
|
||||
|
||||
let selected = selectedLabels.map { (label: $0, selected: true) }
|
||||
let unselected = allLabels.filter { !selectedLabels.contains($0) }.map { (label: $0, selected: false) }
|
||||
labelItems = (selected + unselected).sorted(by: { left, right in
|
||||
(left.label.name ?? "") < (right.label.name ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
GeometryReader { geometry in
|
||||
self.generateContent(in: geometry)
|
||||
}
|
||||
}
|
||||
.frame(height: totalHeight)
|
||||
}
|
||||
|
||||
private func generateContent(in geom: GeometryProxy) -> some View {
|
||||
var width = CGFloat.zero
|
||||
var height = CGFloat.zero
|
||||
|
||||
return ZStack(alignment: .topLeading) {
|
||||
ForEach(self.labelItems, id: \.label.self) { label in
|
||||
self.item(for: label)
|
||||
.padding([.horizontal, .vertical], 6)
|
||||
.alignmentGuide(.leading, computeValue: { dim in
|
||||
if abs(width - dim.width) > geom.size.width {
|
||||
width = 0
|
||||
height -= dim.height
|
||||
}
|
||||
let result = width
|
||||
if label == self.labelItems.last! {
|
||||
width = 0 // last item
|
||||
} else {
|
||||
width -= dim.width
|
||||
}
|
||||
return result
|
||||
})
|
||||
.alignmentGuide(.top, computeValue: { _ in
|
||||
let result = height
|
||||
if label == self.labelItems.last! {
|
||||
height = 0 // last item
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
}
|
||||
.background(viewHeightReader($totalHeight))
|
||||
}
|
||||
|
||||
private func item(for item: (label: LinkedItemLabel, selected: Bool)) -> some View {
|
||||
if item.selected {
|
||||
print(" -- SELECTED LABEL", item.label.name)
|
||||
}
|
||||
print("GETTING ITERATION", iteration)
|
||||
let chip = TextChip(feedItemLabel: item.label, negated: false, checked: item.selected) { chip in
|
||||
onLabelTap(item.label, chip)
|
||||
}
|
||||
return chip
|
||||
}
|
||||
|
||||
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
|
||||
GeometryReader { geometry -> Color in
|
||||
let rect = geometry.frame(in: .local)
|
||||
DispatchQueue.main.async {
|
||||
binding.wrappedValue = rect.size.height
|
||||
}
|
||||
return .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,10 +2,15 @@
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
struct TextToSpeechVoiceSelectionView: View {
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
@StateObject var viewModel = TextToSpeechVoiceSelectionViewModel()
|
||||
|
||||
let language: VoiceLanguage
|
||||
let showLanguageChanger: Bool
|
||||
|
||||
@ -17,55 +22,140 @@
|
||||
var body: some View {
|
||||
Group {
|
||||
Form {
|
||||
if showLanguageChanger {
|
||||
Section("Language") {
|
||||
NavigationLink(destination: TextToSpeechLanguageView().navigationTitle("Language")) {
|
||||
Text(audioController.currentVoiceLanguage.name)
|
||||
if FeatureFlag.enableUltraRealisticVoices, language.key == "en" {
|
||||
if viewModel.waitingForRealisticVoices {
|
||||
HStack {
|
||||
Text("Signing up for beta")
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
} else {
|
||||
Toggle("Use Ultra Realistic Voices", isOn: $viewModel.realisticVoicesToggle)
|
||||
.accentColor(Color.green)
|
||||
}
|
||||
|
||||
if !viewModel.waitingForRealisticVoices, !audioController.ultraRealisticFeatureKey.isEmpty {
|
||||
Text("You are in the ultra realistic voices beta. During the beta you can listen to 10,000 words of audio per day.")
|
||||
.multilineTextAlignment(.leading)
|
||||
} else if audioController.ultraRealisticFeatureRequested {
|
||||
Text("Your request to join the ultra realistic voices demo has been received. You will be informed by email when a spot is available.")
|
||||
.multilineTextAlignment(.leading)
|
||||
} else {
|
||||
Text("Ultra realistic voices are currently in limited beta. Enabling the feature will add you to the beta queue.")
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
innerBody
|
||||
|
||||
if audioController.useUltraRealisticVoices {
|
||||
ultraRealisticVoices
|
||||
} else {
|
||||
if showLanguageChanger {
|
||||
Section("Language") {
|
||||
NavigationLink(destination: TextToSpeechLanguageView().navigationTitle("Language")) {
|
||||
Text(audioController.currentVoiceLanguage.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
standardVoices
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Choose a Voice")
|
||||
.onAppear {
|
||||
viewModel.realisticVoicesToggle = (audioController.useUltraRealisticVoices && !audioController.ultraRealisticFeatureKey.isEmpty)
|
||||
}.onChange(of: viewModel.realisticVoicesToggle) { value in
|
||||
if value, audioController.ultraRealisticFeatureKey.isEmpty {
|
||||
// User wants to sign up
|
||||
viewModel.waitingForRealisticVoices = true
|
||||
Task {
|
||||
await viewModel.requestUltraRealisticFeatureAccess(
|
||||
dataService: self.dataService,
|
||||
audioController: audioController
|
||||
)
|
||||
}
|
||||
} else if value, !audioController.ultraRealisticFeatureKey.isEmpty {
|
||||
audioController.useUltraRealisticVoices = true
|
||||
} else if !value {
|
||||
audioController.useUltraRealisticVoices = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
private var standardVoices: some View {
|
||||
ForEach(language.categories, id: \.self) { category in
|
||||
Section(category.rawValue) {
|
||||
ForEach(audioController.voiceList?.filter { $0.category == category } ?? [], id: \.key.self) { voice in
|
||||
HStack {
|
||||
// Voice samples are not working yet
|
||||
// Button(action: {
|
||||
// audioController.playVoiceSample(voice: voice.key)
|
||||
// }) {
|
||||
// Image(systemName: "play.circle").font(.appTitleTwo)
|
||||
// }
|
||||
// .buttonStyle(PlainButtonStyle())
|
||||
|
||||
Button(action: {
|
||||
audioController.setPreferredVoice(voice.key, forLanguage: language.key)
|
||||
audioController.currentVoice = voice.key
|
||||
}) {
|
||||
HStack {
|
||||
Text(voice.name)
|
||||
Spacer()
|
||||
|
||||
if voice.selected {
|
||||
if audioController.isPlaying, audioController.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
voiceRow(for: voice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var ultraRealisticVoices: some View {
|
||||
ForEach([VoiceCategory.enUS, VoiceCategory.enCA, VoiceCategory.enUK], id: \.self) { category in
|
||||
Section(category.rawValue) {
|
||||
ForEach(audioController.realisticVoiceList?.filter { $0.category == category } ?? [], id: \.key.self) { voice in
|
||||
voiceRow(for: voice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func voiceRow(for voice: VoiceItem) -> some View {
|
||||
HStack {
|
||||
Button(action: {
|
||||
if audioController.isPlayingSample(voice: voice.key) {
|
||||
viewModel.playbackSample = nil
|
||||
audioController.stopVoiceSample()
|
||||
} else {
|
||||
viewModel.playbackSample = voice.key
|
||||
audioController.playVoiceSample(voice: voice.key)
|
||||
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { timer in
|
||||
let playing = audioController.isPlayingSample(voice: voice.key)
|
||||
if playing {
|
||||
viewModel.playbackSample = voice.key
|
||||
} else if !playing {
|
||||
// If the playback sample is something else, its taken ownership
|
||||
// of the value so we just ignore it and shut down our timer.
|
||||
if viewModel.playbackSample == voice.key {
|
||||
viewModel.playbackSample = nil
|
||||
}
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, label: {
|
||||
if viewModel.playbackSample == voice.key {
|
||||
Image(systemName: "stop.circle")
|
||||
.font(.appTitleTwo)
|
||||
.padding(.trailing, 16)
|
||||
} else {
|
||||
Image(systemName: "play.circle")
|
||||
.font(.appTitleTwo)
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
})
|
||||
|
||||
Button(action: {
|
||||
audioController.setPreferredVoice(voice.key, forLanguage: language.key)
|
||||
audioController.currentVoice = voice.key
|
||||
}, label: {
|
||||
HStack {
|
||||
Text(voice.name)
|
||||
Spacer()
|
||||
|
||||
if voice.selected {
|
||||
if audioController.isPlaying, audioController.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
})
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
//
|
||||
// TextToSpeechVoiceSelectionViewModel.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 11/10/22.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor final class TextToSpeechVoiceSelectionViewModel: ObservableObject {
|
||||
@Published var playbackSample: String?
|
||||
@Published var realisticVoicesToggle: Bool = false
|
||||
@Published var waitingForRealisticVoices: Bool = false
|
||||
|
||||
func requestUltraRealisticFeatureAccess(
|
||||
dataService: DataService,
|
||||
audioController: AudioController
|
||||
) async {
|
||||
do {
|
||||
let feature = try await dataService.optInFeature(name: "ultra-realistic-voice")
|
||||
DispatchQueue.main.async {
|
||||
if let feature = feature {
|
||||
audioController.useUltraRealisticVoices = true
|
||||
audioController.ultraRealisticFeatureRequested = true
|
||||
audioController.ultraRealisticFeatureKey = feature.granted ? feature.token : ""
|
||||
if feature.granted, !Voices.isUltraRealisticVoice(audioController.currentVoice) {
|
||||
// Attempt to set to an ultra voice
|
||||
if let voice = Voices.UltraPairs.first {
|
||||
audioController.currentVoice = voice.firstKey
|
||||
}
|
||||
}
|
||||
self.realisticVoicesToggle = true
|
||||
} else {
|
||||
audioController.useUltraRealisticVoices = false
|
||||
audioController.ultraRealisticFeatureKey = ""
|
||||
audioController.ultraRealisticFeatureRequested = false
|
||||
self.realisticVoicesToggle = false
|
||||
}
|
||||
self.waitingForRealisticVoices = false
|
||||
}
|
||||
} catch {
|
||||
print("ERROR OPTING INTO FEATURE", error)
|
||||
audioController.useUltraRealisticVoices = false
|
||||
realisticVoicesToggle = false
|
||||
waitingForRealisticVoices = false
|
||||
audioController.ultraRealisticFeatureRequested = false
|
||||
Snackbar.show(message: "Error signing up for beta. Please try again.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -153,6 +153,60 @@ struct WebReaderContainerView: View {
|
||||
}.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
|
||||
func menuItems(for item: LinkedItem) -> some View {
|
||||
let hasLabels = item.labels?.count == 0
|
||||
let hasHighlights = (item.highlights?.count ?? 0) > 0
|
||||
return Group {
|
||||
if hasHighlights {
|
||||
Button(
|
||||
action: { showHighlightsView = true },
|
||||
label: { Label("View Highlights & Notes", systemImage: "highlighter") }
|
||||
)
|
||||
}
|
||||
Button(
|
||||
action: { showTitleEdit = true },
|
||||
label: { Label("Edit Title/Description", systemImage: "textbox") }
|
||||
)
|
||||
Button(
|
||||
action: editLabels,
|
||||
label: { Label(hasLabels ? "Edit Labels" : "Add Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(
|
||||
action: {
|
||||
archive()
|
||||
},
|
||||
label: {
|
||||
Label(
|
||||
item.isArchived ? "Unarchive" : "Archive",
|
||||
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
)
|
||||
}
|
||||
)
|
||||
Button(
|
||||
action: {
|
||||
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0)
|
||||
},
|
||||
label: { Label("Reset Read Location", systemImage: "arrow.counterclockwise.circle") }
|
||||
)
|
||||
Button(
|
||||
action: {
|
||||
viewModel.downloadAudio(audioController: audioController, item: item)
|
||||
},
|
||||
label: { Label("Download Audio", systemImage: "icloud.and.arrow.down") }
|
||||
)
|
||||
if viewModel.hasOriginalUrl(item) {
|
||||
Button(
|
||||
action: share,
|
||||
label: { Label("Share Original", systemImage: "square.and.arrow.up") }
|
||||
)
|
||||
}
|
||||
Button(
|
||||
action: delete,
|
||||
label: { Label("Delete", systemImage: "trash") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var navBar: some View {
|
||||
HStack(alignment: .center) {
|
||||
#if os(iOS)
|
||||
@ -183,49 +237,7 @@ struct WebReaderContainerView: View {
|
||||
#endif
|
||||
Menu(
|
||||
content: {
|
||||
Group {
|
||||
Button(
|
||||
action: { showHighlightsView = true },
|
||||
label: { Label("View Highlights & Notes", systemImage: "highlighter") }
|
||||
)
|
||||
Button(
|
||||
action: { showTitleEdit = true },
|
||||
label: { Label("Edit Title/Description", systemImage: "textbox") }
|
||||
)
|
||||
Button(
|
||||
action: editLabels,
|
||||
label: { Label("Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(
|
||||
action: {
|
||||
archive()
|
||||
},
|
||||
label: {
|
||||
Label(
|
||||
item.isArchived ? "Unarchive" : "Archive",
|
||||
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
)
|
||||
}
|
||||
)
|
||||
Button(
|
||||
action: {
|
||||
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0)
|
||||
},
|
||||
label: { Label("Reset Read Location", systemImage: "arrow.counterclockwise.circle") }
|
||||
)
|
||||
Button(
|
||||
action: { /* viewModel.downloadAudio(audioController: audioController, item: item) */ },
|
||||
label: { Label("Download Audio", systemImage: "icloud.and.arrow.down") }
|
||||
)
|
||||
Button(
|
||||
action: share,
|
||||
label: { Label("Share Original", systemImage: "square.and.arrow.up") }
|
||||
)
|
||||
Button(
|
||||
action: delete,
|
||||
label: { Label("Delete", systemImage: "trash") }
|
||||
)
|
||||
}
|
||||
menuItems(for: item)
|
||||
},
|
||||
label: {
|
||||
#if os(iOS)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
import WebKit
|
||||
|
||||
struct SafariWebLink: Identifiable {
|
||||
@ -12,6 +13,24 @@ struct SafariWebLink: Identifiable {
|
||||
@Published var articleContent: ArticleContent?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
func hasOriginalUrl(_ item: LinkedItem) -> Bool {
|
||||
if let pageURLString = item.pageURLString, let host = URL(string: pageURLString)?.host {
|
||||
if host == "omnivore.app" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func downloadAudio(audioController: AudioController, item: LinkedItem) {
|
||||
Snackbar.show(message: "Downloading Offline Audio")
|
||||
Task {
|
||||
let downloaded = await audioController.downloadForOffline(itemID: item.unwrappedID)
|
||||
Snackbar.show(message: downloaded ? "Audio file downloaded" : "Error downloading audio")
|
||||
}
|
||||
}
|
||||
|
||||
func loadContent(dataService: DataService, username: String, itemID: String, retryCount: Int = 0) async {
|
||||
errorMessage = nil
|
||||
|
||||
|
||||
@ -62,11 +62,22 @@ public extension LinkedItem {
|
||||
return (pageURLString ?? "").hasSuffix("pdf")
|
||||
}
|
||||
|
||||
func hideHost(_ host: String) -> Bool {
|
||||
switch host {
|
||||
case "storage.googleapis.com":
|
||||
return true
|
||||
case "omnivore.app":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var publisherDisplayName: String? {
|
||||
if let siteName = siteName {
|
||||
return siteName
|
||||
}
|
||||
if let host = URL(string: publisherURLString ?? pageURLString ?? "")?.host, host != "storage.googleapis.com" {
|
||||
if let host = URL(string: publisherURLString ?? pageURLString ?? "")?.host, !hideHost(host) {
|
||||
return host
|
||||
}
|
||||
return nil
|
||||
|
||||
@ -27,172 +27,6 @@
|
||||
case high
|
||||
}
|
||||
|
||||
// Somewhat based on: https://github.com/neekeetab/CachingPlayerItem/blob/master/CachingPlayerItem.swift
|
||||
class SpeechPlayerItem: AVPlayerItem {
|
||||
let resourceLoaderDelegate = ResourceLoaderDelegate()
|
||||
let session: AudioController
|
||||
let speechItem: SpeechItem
|
||||
var speechMarks: [SpeechMark]?
|
||||
|
||||
let completed: () -> Void
|
||||
|
||||
var observer: Any?
|
||||
|
||||
init(session: AudioController, speechItem: SpeechItem, completed: @escaping () -> Void) {
|
||||
self.speechItem = speechItem
|
||||
self.session = session
|
||||
self.completed = completed
|
||||
|
||||
guard let fakeUrl = URL(string: "app.omnivore.speech://\(speechItem.localAudioURL.path).mp3") else {
|
||||
fatalError("internal inconsistency")
|
||||
}
|
||||
|
||||
let asset = AVURLAsset(url: fakeUrl)
|
||||
asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main)
|
||||
|
||||
super.init(asset: asset, automaticallyLoadedAssetKeys: nil)
|
||||
|
||||
resourceLoaderDelegate.owner = self
|
||||
|
||||
self.observer = observe(\.status, options: [.new]) { item, _ in
|
||||
if item.status == .readyToPlay {
|
||||
let duration = CMTimeGetSeconds(item.duration)
|
||||
item.session.updateDuration(forItem: item.speechItem, newDuration: duration)
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: self, queue: OperationQueue.main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.completed()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
observer = nil
|
||||
resourceLoaderDelegate.session?.invalidateAndCancel()
|
||||
}
|
||||
|
||||
open func download() {
|
||||
if resourceLoaderDelegate.session == nil {
|
||||
resourceLoaderDelegate.startDataRequest(with: speechItem.urlRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func playbackStalledHandler() {
|
||||
print("playback stalled...")
|
||||
}
|
||||
|
||||
class ResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {
|
||||
var session: URLSession?
|
||||
var mediaData: Data?
|
||||
var pendingRequests = Set<AVAssetResourceLoadingRequest>()
|
||||
weak var owner: SpeechPlayerItem?
|
||||
|
||||
func resourceLoader(_: AVAssetResourceLoader,
|
||||
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool
|
||||
{
|
||||
if owner == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if session == nil {
|
||||
guard let initialUrl = owner?.speechItem.urlRequest else {
|
||||
fatalError("internal inconsistency")
|
||||
}
|
||||
|
||||
startDataRequest(with: initialUrl)
|
||||
}
|
||||
|
||||
pendingRequests.insert(loadingRequest)
|
||||
processPendingRequests()
|
||||
return true
|
||||
}
|
||||
|
||||
func startDataRequest(with _: URLRequest) {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
session = URLSession(configuration: configuration)
|
||||
|
||||
Task {
|
||||
guard let speechItem = self.owner?.speechItem else {
|
||||
// This probably can't happen, but if it does, just returning should
|
||||
// let AVPlayer try again.
|
||||
print("No speech item found: ", self.owner?.speechItem)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: how do we want to propogate this and handle it in the player
|
||||
let speechData = try? await SpeechSynthesizer.download(speechItem: speechItem, session: self.session)
|
||||
DispatchQueue.main.async {
|
||||
if speechData == nil {
|
||||
self.session = nil
|
||||
}
|
||||
if let owner = self.owner, let speechData = speechData {
|
||||
owner.speechMarks = speechData.speechMarks
|
||||
}
|
||||
self.mediaData = speechData?.audioData
|
||||
|
||||
self.processPendingRequests()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resourceLoader(_: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
|
||||
pendingRequests.remove(loadingRequest)
|
||||
}
|
||||
|
||||
func processPendingRequests() {
|
||||
let requestsFulfilled = Set<AVAssetResourceLoadingRequest>(pendingRequests.compactMap {
|
||||
self.fillInContentInformationRequest($0.contentInformationRequest)
|
||||
if self.haveEnoughDataToFulfillRequest($0.dataRequest!) {
|
||||
$0.finishLoading()
|
||||
return $0
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// remove fulfilled requests from pending requests
|
||||
_ = requestsFulfilled.map { self.pendingRequests.remove($0) }
|
||||
}
|
||||
|
||||
func fillInContentInformationRequest(_ contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) {
|
||||
contentInformationRequest?.contentType = UTType.mp3.identifier
|
||||
|
||||
if let mediaData = mediaData {
|
||||
contentInformationRequest?.isByteRangeAccessSupported = true
|
||||
contentInformationRequest?.contentLength = Int64(mediaData.count)
|
||||
}
|
||||
}
|
||||
|
||||
func haveEnoughDataToFulfillRequest(_ dataRequest: AVAssetResourceLoadingDataRequest) -> Bool {
|
||||
let requestedOffset = Int(dataRequest.requestedOffset)
|
||||
let requestedLength = dataRequest.requestedLength
|
||||
let currentOffset = Int(dataRequest.currentOffset)
|
||||
|
||||
guard let songDataUnwrapped = mediaData,
|
||||
songDataUnwrapped.count > currentOffset
|
||||
else {
|
||||
// Don't have any data at all for this request.
|
||||
return false
|
||||
}
|
||||
|
||||
let bytesToRespond = min(songDataUnwrapped.count - currentOffset, requestedLength)
|
||||
let range = Range(uncheckedBounds: (currentOffset, currentOffset + bytesToRespond))
|
||||
let dataToRespond = songDataUnwrapped.subdata(in: range)
|
||||
dataRequest.respond(with: dataToRespond)
|
||||
|
||||
return songDataUnwrapped.count >= requestedLength + requestedOffset
|
||||
}
|
||||
|
||||
deinit {
|
||||
session?.invalidateAndCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:disable all
|
||||
public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
||||
@Published public var state: AudioControllerState = .stopped
|
||||
@ -205,10 +39,13 @@
|
||||
@Published public var duration: TimeInterval = 0
|
||||
@Published public var timeElapsedString: String?
|
||||
@Published public var durationString: String?
|
||||
@Published public var voiceList: [(name: String, key: String, category: VoiceCategory, selected: Bool)]?
|
||||
@Published public var voiceList: [VoiceItem]?
|
||||
@Published public var realisticVoiceList: [VoiceItem]?
|
||||
|
||||
@Published public var textItems: [String]?
|
||||
|
||||
@Published public var playbackError: Bool = false
|
||||
|
||||
let dataService: DataService
|
||||
|
||||
var timer: Timer?
|
||||
@ -219,11 +56,14 @@
|
||||
var durations: [Double]?
|
||||
var lastReadUpdate = 0.0
|
||||
|
||||
var samplePlayer: AVAudioPlayer?
|
||||
|
||||
public init(dataService: DataService) {
|
||||
self.dataService = dataService
|
||||
|
||||
super.init()
|
||||
self.voiceList = generateVoiceList()
|
||||
self.realisticVoiceList = generateRealisticVoiceList()
|
||||
}
|
||||
|
||||
deinit {
|
||||
@ -277,11 +117,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
public func generateVoiceList() -> [(name: String, key: String, category: VoiceCategory, selected: Bool)] {
|
||||
public func stopWithError() {
|
||||
stop()
|
||||
playbackError = true
|
||||
}
|
||||
|
||||
public func generateVoiceList() -> [VoiceItem] {
|
||||
Voices.Pairs.flatMap { voicePair in
|
||||
[
|
||||
(name: voicePair.firstName, key: voicePair.firstKey, category: voicePair.category, selected: voicePair.firstKey == currentVoice),
|
||||
(name: voicePair.secondName, key: voicePair.secondKey, category: voicePair.category, selected: voicePair.secondKey == currentVoice)
|
||||
VoiceItem(name: voicePair.firstName, key: voicePair.firstKey, category: voicePair.category, selected: voicePair.firstKey == currentVoice),
|
||||
VoiceItem(name: voicePair.secondName, key: voicePair.secondKey, category: voicePair.category, selected: voicePair.secondKey == currentVoice)
|
||||
]
|
||||
}.sorted { $0.name.lowercased() < $1.name.lowercased() }
|
||||
}
|
||||
|
||||
public func generateRealisticVoiceList() -> [VoiceItem] {
|
||||
Voices.UltraPairs.flatMap { voicePair in
|
||||
[
|
||||
VoiceItem(name: voicePair.firstName, key: voicePair.firstKey, category: voicePair.category, selected: voicePair.firstKey == currentVoice),
|
||||
VoiceItem(name: voicePair.secondName, key: voicePair.secondKey, category: voicePair.category, selected: voicePair.secondKey == currentVoice)
|
||||
]
|
||||
}.sorted { $0.name.lowercased() < $1.name.lowercased() }
|
||||
}
|
||||
@ -293,7 +147,7 @@
|
||||
|
||||
for itemID in itemIDs {
|
||||
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document)
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document, speechAuthHeader: speechAuthHeader)
|
||||
do {
|
||||
try await synthesizer.preload()
|
||||
return true
|
||||
@ -307,7 +161,7 @@
|
||||
|
||||
public func downloadForOffline(itemID: String) async -> Bool {
|
||||
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document)
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document, speechAuthHeader: speechAuthHeader)
|
||||
for item in synthesizer.createPlayerItems(from: 0) {
|
||||
do {
|
||||
_ = try await SpeechSynthesizer.download(speechItem: item, redownloadCached: true)
|
||||
@ -419,6 +273,18 @@
|
||||
|
||||
@AppStorage(UserDefaultKey.textToSpeechPreloadEnabled.rawValue) public var preloadEnabled = false
|
||||
|
||||
@AppStorage(UserDefaultKey.textToSpeechUseUltraRealisticVoices.rawValue) public var useUltraRealisticVoices = false
|
||||
|
||||
@AppStorage(UserDefaultKey.textToSpeechUltraRealisticFeatureKey.rawValue) public var ultraRealisticFeatureKey: String = ""
|
||||
@AppStorage(UserDefaultKey.textToSpeechUltraRealisticFeatureRequested.rawValue) public var ultraRealisticFeatureRequested: Bool = false
|
||||
|
||||
var speechAuthHeader: String? {
|
||||
if Voices.isUltraRealisticVoice(currentVoice), !ultraRealisticFeatureKey.isEmpty {
|
||||
return ultraRealisticFeatureKey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public var currentVoiceLanguage: VoiceLanguage {
|
||||
Voices.Languages.first(where: { $0.key == currentLanguage }) ?? Voices.English
|
||||
}
|
||||
@ -458,6 +324,7 @@
|
||||
set {
|
||||
_currentVoice = newValue
|
||||
voiceList = generateVoiceList()
|
||||
realisticVoiceList = generateRealisticVoiceList()
|
||||
|
||||
var currentIdx = 0
|
||||
var currentOffset = 0.0
|
||||
@ -520,7 +387,7 @@
|
||||
// Sometimes we get negatives
|
||||
currentItemOffset = max(currentItemOffset, 0)
|
||||
|
||||
let idx = item.speechItem.audioIdx
|
||||
let idx = currentAudioIndex // item.speechItem.audioIdx
|
||||
let currentItem = document?.utterances[idx].text ?? ""
|
||||
let currentReadIndex = currentItem.index(currentItem.startIndex, offsetBy: min(currentItemOffset, currentItem.count))
|
||||
let lastItem = String(currentItem[..<currentReadIndex])
|
||||
@ -554,7 +421,7 @@
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let document = document {
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: self.dataService.appEnvironment, networker: self.dataService.networker, document: document)
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: self.dataService.appEnvironment, networker: self.dataService.networker, document: document, speechAuthHeader: self.speechAuthHeader)
|
||||
|
||||
self.setTextItems()
|
||||
self.durations = synthesizer.estimatedDurations(forSpeed: self.playbackRate)
|
||||
@ -586,9 +453,15 @@
|
||||
|
||||
public func playVoiceSample(voice: String) {
|
||||
do {
|
||||
if let url = Bundle.main.url(forResource: "tts-voice-sample-\(voice)", withExtension: "mp3") {
|
||||
let player = try AVAudioPlayer(contentsOf: url, fileTypeHint: AVFileType.mp3.rawValue)
|
||||
player.play()
|
||||
pause()
|
||||
|
||||
if let url = Bundle(url: UtilsPackage.bundleURL)?.url(forResource: voice, withExtension: "mp3") {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
|
||||
|
||||
samplePlayer = try AVAudioPlayer(contentsOf: url, fileTypeHint: AVFileType.mp3.rawValue)
|
||||
if !(samplePlayer?.play() ?? false) {
|
||||
throw BasicError.message(messageText: "Unable to playback audio")
|
||||
}
|
||||
} else {
|
||||
NSNotification.operationFailed(message: "Error playing voice sample.")
|
||||
}
|
||||
@ -598,6 +471,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
public func isPlayingSample(voice: String) -> Bool {
|
||||
if let samplePlayer = self.samplePlayer, let url = Bundle(url: UtilsPackage.bundleURL)?.url(forResource: voice, withExtension: "mp3") {
|
||||
return samplePlayer.url == url && samplePlayer.isPlaying
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public func stopVoiceSample() {
|
||||
if let samplePlayer = self.samplePlayer {
|
||||
samplePlayer.stop()
|
||||
self.samplePlayer = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDurations(oldPlayback: Double, newPlayback: Double) {
|
||||
if let oldDurations = durations {
|
||||
durations = oldDurations.map { $0 * oldPlayback / newPlayback }
|
||||
@ -684,10 +571,11 @@
|
||||
if let player = player {
|
||||
observer = player.observe(\.currentItem, options: [.new]) { _, _ in
|
||||
self.currentAudioIndex = (player.currentItem as? SpeechPlayerItem)?.speechItem.audioIdx ?? 0
|
||||
self.updateReadText()
|
||||
}
|
||||
}
|
||||
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document)
|
||||
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document, speechAuthHeader: speechAuthHeader)
|
||||
durations = synthesizer.estimatedDurations(forSpeed: playbackRate)
|
||||
self.synthesizer = synthesizer
|
||||
|
||||
@ -696,9 +584,12 @@
|
||||
|
||||
func synthesizeFrom(start: Int, playWhenReady: Bool, atOffset: Double = 0.0) {
|
||||
if let synthesizer = self.synthesizer, let items = self.synthesizer?.createPlayerItems(from: start) {
|
||||
let prefetchQueue = OperationQueue()
|
||||
prefetchQueue.maxConcurrentOperationCount = 5
|
||||
|
||||
for speechItem in items {
|
||||
let isLast = speechItem.audioIdx == synthesizer.document.utterances.count - 1
|
||||
let playerItem = SpeechPlayerItem(session: self, speechItem: speechItem) {
|
||||
let playerItem = SpeechPlayerItem(session: self, prefetchQueue: prefetchQueue, speechItem: speechItem) {
|
||||
if isLast {
|
||||
self.player?.pause()
|
||||
self.state = .reachedEnd
|
||||
@ -731,6 +622,7 @@
|
||||
}
|
||||
|
||||
public func unpause() {
|
||||
stopVoiceSample()
|
||||
if let player = player {
|
||||
player.rate = Float(playbackRate)
|
||||
state = .playing
|
||||
@ -780,6 +672,14 @@
|
||||
case .reset:
|
||||
if let playerItem = player.currentItem as? SpeechPlayerItem {
|
||||
let itemElapsed = playerItem.status == .readyToPlay ? CMTimeGetSeconds(playerItem.currentTime()) : 0
|
||||
if itemElapsed >= CMTimeGetSeconds(playerItem.duration) + 0.5 {
|
||||
// Occasionally AV wont send an event for a new item starting for ~3s, if this
|
||||
// happens we can try to manually update the time
|
||||
if playerItem.speechItem.audioIdx + 1 < (document?.utterances.count ?? 0) {
|
||||
currentAudioIndex = playerItem.speechItem.audioIdx + 1
|
||||
}
|
||||
}
|
||||
|
||||
timeElapsed = durationBefore(playerIndex: playerItem.speechItem.audioIdx) + itemElapsed
|
||||
timeElapsedString = formatTimeInterval(timeElapsed)
|
||||
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
//
|
||||
// PrefetchSpeechItemOperation.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 11/9/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Models
|
||||
import Utils
|
||||
|
||||
final class PrefetchSpeechItemOperation: Operation, URLSessionDelegate {
|
||||
let speechItem: SpeechItem
|
||||
let session: URLSession
|
||||
|
||||
enum State: Int {
|
||||
case created
|
||||
case started
|
||||
case finished
|
||||
}
|
||||
|
||||
init(speechItem: SpeechItem) {
|
||||
self.speechItem = speechItem
|
||||
self.state = .created
|
||||
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
self.session = URLSession(configuration: configuration)
|
||||
}
|
||||
|
||||
public var state: State = .created {
|
||||
willSet {
|
||||
willChangeValue(forKey: "isReady")
|
||||
willChangeValue(forKey: "isExecuting")
|
||||
willChangeValue(forKey: "isFinished")
|
||||
willChangeValue(forKey: "isCancelled")
|
||||
}
|
||||
didSet {
|
||||
didChangeValue(forKey: "isCancelled")
|
||||
didChangeValue(forKey: "isFinished")
|
||||
didChangeValue(forKey: "isExecuting")
|
||||
didChangeValue(forKey: "isReady")
|
||||
}
|
||||
}
|
||||
|
||||
override var isAsynchronous: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override var isReady: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override var isExecuting: Bool {
|
||||
self.state == .started
|
||||
}
|
||||
|
||||
override var isFinished: Bool {
|
||||
self.state == .finished
|
||||
}
|
||||
|
||||
override func start() {
|
||||
guard !isCancelled else { return }
|
||||
state = .started
|
||||
|
||||
Task {
|
||||
_ = try await SpeechSynthesizer.download(speechItem: speechItem, session: session)
|
||||
state = .finished
|
||||
}
|
||||
}
|
||||
|
||||
override func cancel() {
|
||||
session.invalidateAndCancel()
|
||||
super.cancel()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,208 @@
|
||||
//
|
||||
// SpeechPlayerItem.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 11/9/22.
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
import Models
|
||||
|
||||
// Somewhat based on: https://github.com/neekeetab/CachingPlayerItem/blob/master/CachingPlayerItem.swift
|
||||
class SpeechPlayerItem: AVPlayerItem {
|
||||
let resourceLoaderDelegate = ResourceLoaderDelegate()
|
||||
let session: AudioController
|
||||
let speechItem: SpeechItem
|
||||
var speechMarks: [SpeechMark]?
|
||||
var prefetchOperation: PrefetchSpeechItemOperation?
|
||||
|
||||
let completed: () -> Void
|
||||
|
||||
var observer: Any?
|
||||
|
||||
init(session: AudioController, prefetchQueue: OperationQueue, speechItem: SpeechItem, completed: @escaping () -> Void) {
|
||||
self.speechItem = speechItem
|
||||
self.session = session
|
||||
self.completed = completed
|
||||
|
||||
guard let fakeUrl = URL(string: "app.omnivore.speech://\(speechItem.localAudioURL.path).mp3") else {
|
||||
fatalError("internal inconsistency")
|
||||
}
|
||||
|
||||
let asset = AVURLAsset(url: fakeUrl)
|
||||
asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main)
|
||||
|
||||
super.init(asset: asset, automaticallyLoadedAssetKeys: nil)
|
||||
|
||||
resourceLoaderDelegate.owner = self
|
||||
|
||||
self.observer = observe(\.status, options: [.new]) { item, _ in
|
||||
if item.status == .readyToPlay {
|
||||
let duration = CMTimeGetSeconds(item.duration)
|
||||
item.session.updateDuration(forItem: item.speechItem, newDuration: duration)
|
||||
}
|
||||
if item.status == .failed {
|
||||
item.session.stopWithError()
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
|
||||
object: self, queue: OperationQueue.main
|
||||
) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.completed()
|
||||
}
|
||||
|
||||
self.prefetchOperation = PrefetchSpeechItemOperation(speechItem: speechItem)
|
||||
if let prefetchOperation = self.prefetchOperation {
|
||||
prefetchQueue.addOperation(prefetchOperation)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
observer = nil
|
||||
prefetchOperation?.cancel()
|
||||
resourceLoaderDelegate.session?.invalidateAndCancel()
|
||||
}
|
||||
|
||||
open func download() {
|
||||
if resourceLoaderDelegate.session == nil {
|
||||
resourceLoaderDelegate.startDataRequest(with: speechItem.urlRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func playbackStalledHandler() {
|
||||
print("playback stalled...")
|
||||
}
|
||||
|
||||
class ResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {
|
||||
var session: URLSession?
|
||||
var mediaData: Data?
|
||||
var pendingRequests = Set<AVAssetResourceLoadingRequest>()
|
||||
weak var owner: SpeechPlayerItem?
|
||||
|
||||
func resourceLoader(_: AVAssetResourceLoader,
|
||||
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool
|
||||
{
|
||||
if owner == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if session == nil {
|
||||
guard let initialUrl = owner?.speechItem.urlRequest else {
|
||||
fatalError("internal inconsistency")
|
||||
}
|
||||
|
||||
startDataRequest(with: initialUrl)
|
||||
}
|
||||
|
||||
pendingRequests.insert(loadingRequest)
|
||||
processPendingRequests()
|
||||
return true
|
||||
}
|
||||
|
||||
func startDataRequest(with _: URLRequest) {
|
||||
let configuration = URLSessionConfiguration.default
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
||||
session = URLSession(configuration: configuration)
|
||||
|
||||
Task {
|
||||
guard let speechItem = self.owner?.speechItem else {
|
||||
// This probably can't happen, but if it does, just returning should
|
||||
DispatchQueue.main.async {
|
||||
self.processPlaybackError(error: BasicError.message(messageText: "No speech item found."))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let speechData = try await SpeechSynthesizer.download(speechItem: speechItem, session: self.session ?? URLSession.shared)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if speechData == nil {
|
||||
self.session = nil
|
||||
self.processPlaybackError(error: BasicError.message(messageText: "Unable to download speech data."))
|
||||
return
|
||||
}
|
||||
|
||||
if let owner = self.owner, let speechData = speechData {
|
||||
owner.speechMarks = speechData.speechMarks
|
||||
}
|
||||
self.mediaData = speechData?.audioData
|
||||
|
||||
self.processPendingRequests()
|
||||
}
|
||||
} catch URLError.cancelled {
|
||||
print("cancelled request error being ignored")
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
self.processPlaybackError(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resourceLoader(_: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
|
||||
pendingRequests.remove(loadingRequest)
|
||||
}
|
||||
|
||||
func processPendingRequests() {
|
||||
let requestsFulfilled = Set<AVAssetResourceLoadingRequest>(pendingRequests.compactMap {
|
||||
self.fillInContentInformationRequest($0.contentInformationRequest)
|
||||
if self.haveEnoughDataToFulfillRequest($0.dataRequest!) {
|
||||
$0.finishLoading()
|
||||
return $0
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// remove fulfilled requests from pending requests
|
||||
_ = requestsFulfilled.map { self.pendingRequests.remove($0) }
|
||||
}
|
||||
|
||||
func processPlaybackError(error: Error?) {
|
||||
let requestsFulfilled = Set<AVAssetResourceLoadingRequest>(pendingRequests.compactMap {
|
||||
$0.finishLoading(with: error)
|
||||
return nil
|
||||
})
|
||||
|
||||
_ = requestsFulfilled.map { self.pendingRequests.remove($0) }
|
||||
}
|
||||
|
||||
func fillInContentInformationRequest(_ contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) {
|
||||
contentInformationRequest?.contentType = UTType.mp3.identifier
|
||||
|
||||
if let mediaData = mediaData {
|
||||
contentInformationRequest?.isByteRangeAccessSupported = true
|
||||
contentInformationRequest?.contentLength = Int64(mediaData.count)
|
||||
}
|
||||
}
|
||||
|
||||
func haveEnoughDataToFulfillRequest(_ dataRequest: AVAssetResourceLoadingDataRequest) -> Bool {
|
||||
let requestedOffset = Int(dataRequest.requestedOffset)
|
||||
let requestedLength = dataRequest.requestedLength
|
||||
let currentOffset = Int(dataRequest.currentOffset)
|
||||
|
||||
guard let songDataUnwrapped = mediaData,
|
||||
songDataUnwrapped.count > currentOffset
|
||||
else {
|
||||
// Don't have any data at all for this request.
|
||||
return false
|
||||
}
|
||||
|
||||
let bytesToRespond = min(songDataUnwrapped.count - currentOffset, requestedLength)
|
||||
let range = Range(uncheckedBounds: (currentOffset, currentOffset + bytesToRespond))
|
||||
let dataToRespond = songDataUnwrapped.subdata(in: range)
|
||||
dataRequest.respond(with: dataToRespond)
|
||||
|
||||
return songDataUnwrapped.count >= requestedLength + requestedOffset
|
||||
}
|
||||
|
||||
deinit {
|
||||
session?.invalidateAndCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ struct UtteranceRequest: Codable {
|
||||
let voice: String
|
||||
let language: String
|
||||
let rate: String
|
||||
let isUltraRealisticVoice: Bool
|
||||
}
|
||||
|
||||
struct Utterance: Decodable {
|
||||
@ -26,10 +27,12 @@ struct Utterance: Decodable {
|
||||
public let wordCount: Double
|
||||
|
||||
func toSSML(document: SpeechDocument) throws -> Data? {
|
||||
let usedVoice = voice ?? document.defaultVoice
|
||||
let request = UtteranceRequest(text: text,
|
||||
voice: voice ?? document.defaultVoice,
|
||||
voice: usedVoice,
|
||||
language: document.language,
|
||||
rate: "1.1")
|
||||
rate: "1.1",
|
||||
isUltraRealisticVoice: Voices.isUltraRealisticVoice(usedVoice))
|
||||
return try JSONEncoder().encode(request)
|
||||
}
|
||||
}
|
||||
@ -78,11 +81,13 @@ struct SpeechSynthesizer {
|
||||
let document: SpeechDocument
|
||||
let appEnvironment: AppEnvironment
|
||||
let networker: Networker
|
||||
let speechAuthHeader: String?
|
||||
|
||||
init(appEnvironment: AppEnvironment, networker: Networker, document: SpeechDocument) {
|
||||
init(appEnvironment: AppEnvironment, networker: Networker, document: SpeechDocument, speechAuthHeader: String?) {
|
||||
self.appEnvironment = appEnvironment
|
||||
self.networker = networker
|
||||
self.document = document
|
||||
self.speechAuthHeader = speechAuthHeader
|
||||
}
|
||||
|
||||
func estimatedDurations(forSpeed speed: Double) -> [Double] {
|
||||
@ -120,7 +125,7 @@ struct SpeechSynthesizer {
|
||||
func createPlayerItems(from: Int) -> [SpeechItem] {
|
||||
var result: [SpeechItem] = []
|
||||
|
||||
for idx in from ..< min(7, document.utterances.count) {
|
||||
for idx in from ..< document.utterances.count {
|
||||
let utterance = document.utterances[idx]
|
||||
let voiceStr = utterance.voice ?? document.defaultVoice
|
||||
let segmentStr = String(format: "%04d", arguments: [idx])
|
||||
@ -156,34 +161,52 @@ struct SpeechSynthesizer {
|
||||
request.setValue(value, forHTTPHeaderField: header)
|
||||
}
|
||||
|
||||
if let speechAuthHeader = speechAuthHeader {
|
||||
request.setValue(speechAuthHeader, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
static func downloadData(session: URLSession, request: URLRequest) async throws -> Data {
|
||||
do {
|
||||
let result: (Data, URLResponse)? = try await session.data(for: request)
|
||||
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
|
||||
print("error: ", result?.1)
|
||||
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
|
||||
}
|
||||
|
||||
guard let data = result?.0 else {
|
||||
throw BasicError.message(messageText: "audioFetch failed. no data received.")
|
||||
}
|
||||
|
||||
return data
|
||||
} catch URLError.cancelled {
|
||||
print("cancled request error being ignored")
|
||||
return Data()
|
||||
} catch {
|
||||
print("ERROR DOWNLOADING AUDIO DATA", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static func download(speechItem: SpeechItem,
|
||||
redownloadCached: Bool = false,
|
||||
session: URLSession? = URLSession.shared) async throws -> SynthesizeData?
|
||||
session: URLSession = URLSession.shared) async throws -> SynthesizeData?
|
||||
{
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
if !redownloadCached {
|
||||
if let speechMarksData = try? Data(contentsOf: speechItem.localSpeechURL),
|
||||
let speechMarks = try? decoder.decode([SpeechMark].self, from: speechMarksData),
|
||||
let localData = try? Data(contentsOf: speechItem.localAudioURL)
|
||||
{
|
||||
if let localData = try? Data(contentsOf: speechItem.localAudioURL) {
|
||||
var speechMarks: [SpeechMark]?
|
||||
if let speechMarksData = try? Data(contentsOf: speechItem.localSpeechURL) {
|
||||
speechMarks = try? decoder.decode([SpeechMark].self, from: speechMarksData)
|
||||
}
|
||||
return SynthesizeData(audioData: localData, speechMarks: speechMarks)
|
||||
}
|
||||
}
|
||||
|
||||
let request = speechItem.urlRequest
|
||||
let result: (Data, URLResponse)? = try? await (session ?? URLSession.shared).data(for: request)
|
||||
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
|
||||
print("error: ", result?.1 as Any)
|
||||
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
|
||||
}
|
||||
|
||||
guard let data = result?.0 else {
|
||||
throw BasicError.message(messageText: "audioFetch failed. no data received.")
|
||||
}
|
||||
let data = try await downloadData(session: session, request: speechItem.urlRequest)
|
||||
|
||||
let tempPath = FileManager.default
|
||||
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
@ -204,8 +227,6 @@ struct SpeechSynthesizer {
|
||||
try? FileManager.default.removeItem(at: speechItem.localAudioURL)
|
||||
try FileManager.default.moveItem(at: tempPath, to: speechItem.localAudioURL)
|
||||
|
||||
let savedData = try? Data(contentsOf: speechItem.localAudioURL)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let speechMarksData = try encoder.encode(jsonData.speechMarks)
|
||||
try speechMarksData.write(to: tempSMPath)
|
||||
@ -214,6 +235,7 @@ struct SpeechSynthesizer {
|
||||
|
||||
return SynthesizeData(audioData: audioData, speechMarks: jsonData.speechMarks)
|
||||
} catch {
|
||||
print("ERROR DOWNLOADING SPEECH DATA:", error)
|
||||
let errorMessage = "audioFetch failed. could not write MP3 data to disk"
|
||||
throw BasicError.message(messageText: errorMessage)
|
||||
}
|
||||
@ -222,12 +244,12 @@ struct SpeechSynthesizer {
|
||||
|
||||
struct SynthesizeResult: Decodable {
|
||||
let audioData: String
|
||||
let speechMarks: [SpeechMark]
|
||||
let speechMarks: [SpeechMark]?
|
||||
}
|
||||
|
||||
struct SynthesizeData: Decodable {
|
||||
let audioData: Data
|
||||
let speechMarks: [SpeechMark]
|
||||
let speechMarks: [SpeechMark]?
|
||||
}
|
||||
|
||||
extension Data {
|
||||
|
||||
@ -14,6 +14,13 @@ public struct VoiceLanguage {
|
||||
public let categories: [VoiceCategory]
|
||||
}
|
||||
|
||||
public struct VoiceItem {
|
||||
public let name: String
|
||||
public let key: String
|
||||
public let category: VoiceCategory
|
||||
public let selected: Bool
|
||||
}
|
||||
|
||||
public enum VoiceCategory: String, CaseIterable {
|
||||
case enUS = "English (US)"
|
||||
case enAU = "English (Australia)"
|
||||
@ -29,10 +36,10 @@ public enum VoiceCategory: String, CaseIterable {
|
||||
}
|
||||
|
||||
public struct VoicePair {
|
||||
let firstKey: String
|
||||
public let firstKey: String
|
||||
let secondKey: String
|
||||
|
||||
let firstName: String
|
||||
public let firstName: String
|
||||
let secondName: String
|
||||
|
||||
let language: String
|
||||
@ -40,6 +47,12 @@ public struct VoicePair {
|
||||
}
|
||||
|
||||
public enum Voices {
|
||||
public static func isUltraRealisticVoice(_ voiceKey: String) -> Bool {
|
||||
UltraPairs.contains(where: { voice in
|
||||
voice.firstKey == voiceKey || voice.secondKey == voiceKey
|
||||
})
|
||||
}
|
||||
|
||||
public static let English = VoiceLanguage(key: "en",
|
||||
name: "English",
|
||||
defaultVoice: "en-US-ChristopherNeural",
|
||||
@ -72,4 +85,20 @@ public enum Voices {
|
||||
VoicePair(firstKey: "de-DE-ChristophNeural", secondKey: "de-DE-LouisaNeural", firstName: "Christoph", secondName: "Louisa", language: "de-DE", category: .deDE),
|
||||
VoicePair(firstKey: "ja-JP-NanamiNeural", secondKey: "ja-JP-KeitaNeural", firstName: "Nanami", secondName: "Keita", language: "ja-JP", category: .jaJP)
|
||||
]
|
||||
|
||||
public static let UltraPairs = [
|
||||
VoicePair(firstKey: "Larry", secondKey: "Susan", firstName: "Larry", secondName: "Susan", language: "en-US", category: .enUS),
|
||||
VoicePair(firstKey: "Jordan", secondKey: "William", firstName: "Jordan", secondName: "William", language: "en-US", category: .enUS),
|
||||
VoicePair(firstKey: "Evelyn", secondKey: "Axel", firstName: "Evelyn", secondName: "Axel", language: "en-US", category: .enUS),
|
||||
VoicePair(firstKey: "Nova", secondKey: "Owen", firstName: "Nova", secondName: "Owen", language: "en-US", category: .enUS),
|
||||
VoicePair(firstKey: "Frankie", secondKey: "Natalie", firstName: "Frankie", secondName: "Natalie", language: "en-US", category: .enUS),
|
||||
|
||||
VoicePair(firstKey: "Daniel", secondKey: "Charlotte", firstName: "Daniel", secondName: "Charlotte", language: "en-CA", category: .enCA),
|
||||
VoicePair(firstKey: "Lillian", secondKey: "Aurora", firstName: "Lillian", secondName: "Aurora", language: "en-UK", category: .enUK),
|
||||
|
||||
VoicePair(firstKey: "Oliver", secondKey: "Arthur", firstName: "Oliver", secondName: "Arthur", language: "en-UK", category: .enUK),
|
||||
VoicePair(firstKey: "Frederick", secondKey: "Hunter", firstName: "Frederick", secondName: "Hunter", language: "en-UK", category: .enUK),
|
||||
VoicePair(firstKey: "Nolan", secondKey: "Phoebe", firstName: "Nolan", secondName: "Phoebe", language: "en-UK", category: .enUK),
|
||||
VoicePair(firstKey: "Daisy", secondKey: "Stella", firstName: "Daisy", secondName: "Stella", language: "en-UK", category: .enUK)
|
||||
]
|
||||
}
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 11/10/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public struct Feature {
|
||||
public let name: String
|
||||
public let token: String
|
||||
public let granted: Bool
|
||||
}
|
||||
|
||||
public extension DataService {
|
||||
func optInFeature(name: String) async throws -> Feature? {
|
||||
enum MutationResult {
|
||||
case success(feature: Feature)
|
||||
case error(errorCode: Enums.OptInFeatureErrorCode)
|
||||
}
|
||||
|
||||
let featureSelection = Selection.Feature { Feature(name: try $0.name(), token: try $0.token(), granted: try $0.grantedAt() != nil) }
|
||||
let selection = Selection<MutationResult, Unions.OptInFeatureResult> {
|
||||
try $0.on(
|
||||
optInFeatureError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) },
|
||||
optInFeatureSuccess: .init { .success(feature: try $0.feature(selection: featureSelection)) }
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.optInFeature(input: InputObjects.OptInFeatureInput(name: name),
|
||||
selection: selection)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { queryResult in
|
||||
guard let payload = try? queryResult.get() else {
|
||||
continuation.resume(throwing: BasicError.message(messageText: "network error"))
|
||||
return
|
||||
}
|
||||
|
||||
switch payload.data {
|
||||
case let .success(feature: feature):
|
||||
continuation.resume(returning: feature)
|
||||
case let .error(errorCode: errorCode):
|
||||
continuation.resume(throwing: BasicError.message(messageText: errorCode.rawValue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,5 +14,5 @@ public enum FeatureFlag {
|
||||
public static let enableShareButton = false
|
||||
public static let enableSnooze = false
|
||||
public static let enableGridCardsOnPhone = false
|
||||
public static let enableHighlightsView = true
|
||||
public static let enableUltraRealisticVoices = false
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Axel.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Axel.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Daisy.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Daisy.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Ellie.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Ellie.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Larry.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Larry.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Nolan.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Nolan.mp3
Normal file
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Nova.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Nova.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Owen.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Owen.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Susan.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Susan.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -17,6 +17,9 @@ public enum UserDefaultKey: String {
|
||||
case textToSpeechPreferredVoice
|
||||
case textToSpeechDefaultLanguage
|
||||
case textToSpeechPreloadEnabled
|
||||
case textToSpeechUseUltraRealisticVoices
|
||||
case textToSpeechUltraRealisticFeatureKey
|
||||
case textToSpeechUltraRealisticFeatureRequested
|
||||
case recentSearchTerms
|
||||
case audioPlayerExpanded
|
||||
case themeName
|
||||
|
||||
@ -3,6 +3,7 @@ import SwiftUI
|
||||
public extension Color {
|
||||
static var appBackground: Color { Color("_background", bundle: .module) }
|
||||
static var appDeepBackground: Color { Color("_deepBackground", bundle: .module) }
|
||||
static var appGreenSuccess: Color { Color("_appGreenSuccess", bundle: .module) }
|
||||
|
||||
// GrayScale -- adapted from Radix Colors
|
||||
static var appGrayBorder: Color { Color("_grayBorder", bundle: .module) }
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.294",
|
||||
"green" : "0.843",
|
||||
"red" : "0.196"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.294",
|
||||
"green" : "0.843",
|
||||
"red" : "0.196"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,6 @@ public enum GridCardAction {
|
||||
case delete
|
||||
case editLabels
|
||||
case editTitle
|
||||
case downloadAudio
|
||||
case viewHighlights
|
||||
}
|
||||
|
||||
@ -46,17 +45,19 @@ public struct GridCard: View {
|
||||
|
||||
var contextMenuView: some View {
|
||||
Group {
|
||||
Button(
|
||||
action: { menuActionHandler(.viewHighlights) },
|
||||
label: { Label("View Highlights & Notes", systemImage: "highlighter") }
|
||||
)
|
||||
if (item.highlights?.count ?? 0) > 0 {
|
||||
Button(
|
||||
action: { menuActionHandler(.viewHighlights) },
|
||||
label: { Label("View Highlights & Notes", systemImage: "highlighter") }
|
||||
)
|
||||
}
|
||||
Button(
|
||||
action: { menuActionHandler(.editTitle) },
|
||||
label: { Label("Edit Title/Description", systemImage: "textbox") }
|
||||
)
|
||||
Button(
|
||||
action: { menuActionHandler(.editLabels) },
|
||||
label: { Label("Edit Labels", systemImage: "tag") }
|
||||
label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(
|
||||
action: { menuActionHandler(.toggleArchiveStatus) },
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -5,10 +5,14 @@ import Utils
|
||||
public struct TextChip: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
let checked: Bool
|
||||
var onTap: ((TextChip) -> Void)?
|
||||
|
||||
public init(text: String, color: Color, negated: Bool = false) {
|
||||
self.text = text
|
||||
self.color = color
|
||||
self.negated = negated
|
||||
self.checked = false
|
||||
}
|
||||
|
||||
public init?(feedItemLabel: LinkedItemLabel, negated: Bool = false) {
|
||||
@ -17,9 +21,22 @@ public struct TextChip: View {
|
||||
self.text = feedItemLabel.name ?? ""
|
||||
self.color = color
|
||||
self.negated = negated
|
||||
self.checked = false
|
||||
}
|
||||
|
||||
let text: String
|
||||
public init?(feedItemLabel: LinkedItemLabel, negated: Bool = false, checked: Bool = false, onTap: ((TextChip) -> Void)?) {
|
||||
guard let color = Color(hex: feedItemLabel.color ?? "") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.text = feedItemLabel.name ?? ""
|
||||
self.color = color
|
||||
self.negated = negated
|
||||
self.onTap = onTap
|
||||
self.checked = checked
|
||||
}
|
||||
|
||||
public let text: String
|
||||
let color: Color
|
||||
let negated: Bool
|
||||
|
||||
@ -53,16 +70,31 @@ public struct TextChip: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Text(text)
|
||||
.strikethrough(color: negated ? textColor : .clear)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.font(.appCaptionBold)
|
||||
.foregroundColor(textColor)
|
||||
.lineLimit(1)
|
||||
.background(Capsule().fill(backgroundColor))
|
||||
.overlay(Capsule().stroke(borderColor, lineWidth: 1))
|
||||
.padding(1)
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Text(text)
|
||||
.strikethrough(color: negated ? textColor : .clear)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.font(.appCaptionBold)
|
||||
.foregroundColor(textColor)
|
||||
.lineLimit(1)
|
||||
.background(Capsule().fill(backgroundColor))
|
||||
.overlay(Capsule().stroke(borderColor, lineWidth: 1))
|
||||
.padding(1)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if checked {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.appBody)
|
||||
.symbolVariant(.circle.fill)
|
||||
.foregroundStyle(Color.appBackground, Color.appGreenSuccess)
|
||||
.padding([.top, .trailing], -6)
|
||||
}
|
||||
}
|
||||
}.onTapGesture {
|
||||
if let onTap = onTap {
|
||||
onTap(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,7 +108,7 @@ public struct TextChipButton: View {
|
||||
}
|
||||
|
||||
public static func makeSearchFilterButton(title: String, onTap: @escaping () -> Void) -> TextChipButton {
|
||||
TextChipButton(title: "Search: \(title)", color: .appCtaYellow, actionType: .clear, negated: false, onTap: onTap)
|
||||
TextChipButton(title: title, color: .appCtaYellow, actionType: .filter, negated: false, onTap: onTap)
|
||||
}
|
||||
|
||||
public static func makeShowOptionsButton(title: String, onTap: @escaping () -> Void) -> TextChipButton {
|
||||
@ -101,11 +133,11 @@ public struct TextChipButton: View {
|
||||
case remove
|
||||
case add
|
||||
case show
|
||||
case clear
|
||||
case filter
|
||||
|
||||
var systemIconName: String {
|
||||
switch self {
|
||||
case .clear, .remove:
|
||||
case .filter, .remove:
|
||||
return "xmark"
|
||||
case .add:
|
||||
return "plus"
|
||||
@ -139,6 +171,10 @@ public struct TextChipButton: View {
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
if actionType == .filter {
|
||||
Image(systemName: "line.3.horizontal.decrease")
|
||||
}
|
||||
|
||||
Text(text)
|
||||
.strikethrough(color: negated ? foregroundColor : .clear)
|
||||
.padding(.leading, 3)
|
||||
|
||||
@ -12,16 +12,17 @@ type ArticleSubtitleProps = {
|
||||
hideButton?: boolean
|
||||
}
|
||||
|
||||
export function ArticleSubtitle(props: ArticleSubtitleProps): JSX.Element {
|
||||
export function ArticleSubtitle(props: ArticleSubtitleProps): JSX.Element {
|
||||
const textStyle = props.style || 'footnote'
|
||||
const subtitle = articleSubtitle(props.href, props.author)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<StyledText style={textStyle} css={{ wordBreak: 'break-word' }}>
|
||||
{articleSubtitle(props.href, props.author)}{' '}
|
||||
<span style={{ position: 'relative', bottom: 1 }}>• </span>{' '}
|
||||
{subtitle}{' '}
|
||||
{subtitle && (<span style={{ position: 'relative', bottom: 1 }}>• </span>)}{' '}
|
||||
{formattedLongDate(props.rawDisplayDate)}{' '}
|
||||
{!props.hideButton && (
|
||||
{!props.hideButton && !shouldHideUrl(props.href) && (
|
||||
<>
|
||||
<span style={{ position: 'relative', bottom: 1 }}>• </span>{' '}
|
||||
<StyledLink
|
||||
@ -42,11 +43,26 @@ export function ArticleSubtitle(props: ArticleSubtitleProps): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
function articleSubtitle(url: string, author?: string): string {
|
||||
function shouldHideUrl(url: string): boolean {
|
||||
const origin = new URL(url).origin
|
||||
const hideHosts = ['https://storage.googleapis.com', 'https://omnivore.app']
|
||||
if (hideHosts.indexOf(origin) != -1) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function articleSubtitle(url: string, author?: string): string | undefined {
|
||||
const origin = new URL(url).origin
|
||||
const hideUrl = shouldHideUrl(url)
|
||||
if (author) {
|
||||
return `${authoredByText(author)}, ${new URL(url).hostname}`
|
||||
const auth = `${authoredByText(author)}`
|
||||
return hideUrl ? auth : `${auth}, ${new URL(url).hostname}`
|
||||
} else {
|
||||
return new URL(url).origin
|
||||
if (hideUrl) {
|
||||
return undefined
|
||||
}
|
||||
return origin
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user