Merge pull request #1396 from omnivore-app/feat/ultra-voices

iOS work on realistic voices
This commit is contained in:
Jackson Harper
2022-11-15 08:54:59 +08:00
committed by GitHub
93 changed files with 1425 additions and 458 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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