Update TTS user interface

This commit is contained in:
Jackson Harper 2023-02-20 22:49:09 +08:00
parent 40567a6ed9
commit c9296deac4
36 changed files with 803 additions and 216 deletions

View file

@ -47,7 +47,9 @@
},
label: {
Image(systemName: playPauseButtonImage)
.font(.appIconLarge)
.resizable(resizingMode: Image.ResizingMode.stretch)
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.light))
}
))
}
@ -113,42 +115,55 @@
}
}
func defaultArtwork(forDimensions dim: Double) -> some View {
ZStack(alignment: .center) {
Color.appButtonBackground
.frame(width: dim, height: dim)
.cornerRadius(6)
Image(systemName: "headphones")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: dim / 2, height: dim / 2)
}
}
struct SpeechCard: View {
let id: Int
@EnvironmentObject var audioController: AudioController
var intervalFormatter: DateComponentsFormatter {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.minute, .second]
formatter.zeroFormattingBehavior = .pad
return formatter
}
var body: some View {
Group {
let isCurrent = id == self.audioController.currentAudioIndex
ZStack(alignment: .top) {
Text(intervalFormatter.string(from: self.audioController.offsets?[id] ?? 0.0) ?? "")
.font(Font.system(size: 11, weight: isCurrent ? .medium : .regular))
.foregroundColor(isCurrent ? Color.themeTTSReadingText : Color(hex: "#898989"))
.padding(.leading, 8)
.padding(.top, 2)
.frame(maxWidth: .infinity, alignment: .leading)
if id != self.audioController.currentAudioIndex || self.audioController.isLoading {
Text(self.audioController.textItems?[id] ?? "\(id)")
.font(.textToSpeechRead.leading(.loose))
.foregroundColor(Color.appGrayTextContrast)
.font(.appCallout)
.foregroundColor(Color(hex: "#898989"))
.padding(.leading, 48 + 8)
.padding(.trailing, 16)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Group {
Text(audioController.readText)
.font(.textToSpeechRead.leading(.loose))
.foregroundColor(Color.appGrayTextContrast)
.font(.appTextToSpeechCurrent)
.foregroundColor(Color.themeTTSReadingText)
+
Text(audioController.unreadText)
.font(.textToSpeechRead.leading(.loose))
.foregroundColor(audioController.useUltraRealisticVoices ? Color.appGrayTextContrast : Color.appGrayText)
.font(.appTextToSpeechCurrent)
.foregroundColor(Color.themeTTSReadingText)
}
.padding(.leading, 48 + 8)
.padding(.trailing, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(16)
.padding(.horizontal, 8)
.padding(.vertical, 15)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isCurrent ? Color.themeHighlightColor : Color.themeDisabledBG)
}
init(id: Int) {
@ -160,27 +175,51 @@
ZStack {
let textItems = self.audioController.textItems ?? []
if textItems.count > 0 {
TabView(selection: $tabIndex) {
ForEach(0 ..< textItems.count, id: \.self) { id in
SpeechCard(id: id)
.tag(id)
ScrollViewReader { scroller in
List {
ForEach(0 ..< textItems.count, id: \.self) { id in
SpeechCard(id: id)
.tag(id)
.onTapGesture {
audioController.seek(toUtterance: id)
}
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
// Extra bottom padding, so the controls appear to be over the list
Color.themeDisabledBG
.frame(maxWidth: .infinity)
.frame(height: 150)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.onChange(of: tabIndex, perform: { index in
if index != audioController.currentAudioIndex, index < (audioController.textItems?.count ?? 0) {
audioController.seek(toUtterance: index)
}
})
.onChange(of: audioController.currentAudioIndex, perform: { index in
if index >= textItems.count {
return
.listStyle(.plain)
.onAppear {
if audioController.currentAudioIndex < textItems.count {
withAnimation {
scroller.scrollTo(audioController.currentAudioIndex, anchor: .center)
}
}
}
.onChange(of: audioController.currentAudioIndex, perform: { index in
if index >= textItems.count {
return
}
if self.audioController.state != .reachedEnd {
tabIndex = index
}
})
if self.audioController.state != .reachedEnd {
withAnimation {
scroller.scrollTo(index, anchor: .center)
}
}
})
// .simultaneousGesture(
//// DragGesture().onChanged {
//// let isScrollDown = $0.translation.height > 0
//// print(isScrollDown)
//// }
//// )
}
.background(Color.themeDisabledBG)
}
if audioController.state == .reachedEnd {
@ -208,29 +247,12 @@
Text(LocalText.audioPlayerReplay)
}
}
).buttonStyle(RoundedRectButtonStyle())
)
.padding(.bottom, 138)
.buttonStyle(RoundedRectButtonStyle())
}
}
}
var header: some View {
ZStack {
closeButton
.padding(.top, 24)
.padding(.leading, 16)
.frame(maxWidth: .infinity, alignment: .leading)
Capsule()
.fill(.gray)
.frame(width: 60, height: 4)
.padding(.top, 8)
.transition(.opacity)
menuButton
.padding(.top, 24)
.padding(.trailing, 16)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
var scrubber: some View {
@ -244,65 +266,103 @@
self.audioController.scrubState = .scrubEnded(self.audioController.timeElapsed)
}
})
.padding(.top, 26)
HStack {
Text(audioController.timeElapsedString ?? "0:00")
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
.font(Font.system(size: 10))
.foregroundColor(Color(hex: "#9D9D9B"))
Spacer()
Text(audioController.durationString ?? "0:00")
.font(.appCaptionTwo)
.foregroundColor(.appGrayText)
.font(Font.system(size: 10))
.foregroundColor(Color(hex: "#9D9D9B"))
}
}
.padding(.leading, 16)
.padding(.trailing, 16)
.padding(.leading, 42)
.padding(.trailing, 42)
}
var audioButtons: some View {
HStack(alignment: .center) {
Spacer()
Button(action: { showVoiceSheet = true }, label: {
Image(systemName: "person.wave.2")
.resizable()
.frame(width: 18, height: 18)
})
.padding(.trailing, 32)
Button(
action: { self.audioController.skipBackwards(seconds: 30) },
label: {
Image(systemName: "gobackward.30")
.font(.appTitleTwo)
.resizable()
.font(Font.title.weight(.light))
}
)
Spacer()
.frame(width: 16, height: 16)
.padding(.trailing, 16)
.foregroundColor(.themeDarkGray)
playPauseButtonItem
.frame(width: 56, height: 56)
Spacer()
.frame(width: 45, height: 45)
.padding(.trailing, 16)
.foregroundColor(.themeDarkGray)
Button(
action: { self.audioController.skipForward(seconds: 30) },
label: {
Image(systemName: "goforward.30")
.font(.appTitleTwo)
.resizable()
.font(Font.title.weight(.light))
}
)
Spacer()
.frame(width: 16, height: 16)
.padding(.trailing, 32 - 4) // -4 to account for the menu touch padding
.foregroundColor(.themeDarkGray)
}.padding(.bottom, 16)
Menu(content: {
playbackRateButton(rate: 0.8, title: "0.8×", selected: audioController.playbackRate == 0.8)
playbackRateButton(rate: 0.9, title: "0.9×", selected: audioController.playbackRate == 0.9)
playbackRateButton(rate: 1.0, title: "1.0×", selected: audioController.playbackRate == 1.0)
playbackRateButton(rate: 1.1, title: "1.1×", selected: audioController.playbackRate == 1.1)
playbackRateButton(rate: 1.2, title: "1.2×", selected: audioController.playbackRate == 1.2)
playbackRateButton(rate: 1.5, title: "1.5×", selected: audioController.playbackRate == 1.5)
playbackRateButton(rate: 1.7, title: "1.7×", selected: audioController.playbackRate == 1.7)
playbackRateButton(rate: 2.0, title: "2.0×", selected: audioController.playbackRate == 2.0)
}, label: {
Text("\(String(format: "%.1f", audioController.playbackRate))×")
.font(.appCaption)
})
.padding(4)
Spacer()
}
.foregroundColor(Color.themeMediumGray)
.padding(.bottom, 16)
}
func playerContent(_: LinkedItemAudioProperties) -> some View {
VStack(spacing: 0) {
header
ZStack {
audioCards
.frame(maxHeight: .infinity)
Spacer()
VStack {
Spacer()
scrubber
audioButtons
VStack {
scrubber
audioButtons
}
.frame(height: 138)
.background(Color.themeSolidBackground)
.padding(.bottom, 8)
.cornerRadius(8)
.padding(.bottom, -8)
}
}
.padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.padding(0)
.onAppear {
self.tabIndex = audioController.currentAudioIndex
}
@ -317,10 +377,8 @@
TextToSpeechVoiceSelectionView(forLanguage: audioController.currentVoiceLanguage, showLanguageChanger: true)
.navigationBarTitle("Voice")
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(leading: Button(action: { self.showVoiceSheet = false }, label: {
Image(systemName: "chevron.backward")
.font(.appNavbarIcon)
.tint(.appGrayTextContrast)
.navigationBarItems(trailing: Button(action: { self.showVoiceSheet = false }, label: {
Text("Done").bold()
}))
}
}
@ -342,16 +400,33 @@
.buttonStyle(PlainButtonStyle())
}
@State var queryString: String = ""
public var body: some View {
NavigationView {
innerBody
.background(Color.themeDisabledBG)
.navigationTitle(LocalText.textToSpeechGeneric)
.navigationBarItems(trailing: Button(action: { dismiss() }, label: { Text("Hide") }))
.navigationBarTitleDisplayMode(NavigationBarItem.TitleDisplayMode.inline)
// .searchable(text: $queryString, placement: .navigationBarDrawer(displayMode: .always)) {
// // print("searching: ", queryString)
// Text("content")
// }
// }
}
}
public var innerBody: some View {
if let itemAudioProperties = self.audioController.itemAudioProperties {
playerContent(itemAudioProperties)
return AnyView(playerContent(itemAudioProperties)
.tint(.appGrayTextContrast)
.alert("There was an error playing back your audio.",
isPresented: $audioController.playbackError) {
Button(LocalText.dismissButton, role: .none) {}
}
})
} else {
EmptyView()
return AnyView(EmptyView())
}
}

View file

@ -53,7 +53,9 @@
},
label: {
Image(systemName: playPauseButtonImage)
.font(.appTitleTwo)
.resizable(resizingMode: Image.ResizingMode.stretch)
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.light))
}
))
}
@ -65,10 +67,21 @@
audioController.stop()
},
label: {
Image(systemName: "xmark")
.font(.appTitleTwo)
ZStack {
Circle()
.foregroundColor(Color(hex: "#3D3D3D"))
Image(systemName: "xmark")
.resizable(resizingMode: Image.ResizingMode.stretch)
.foregroundColor(Color(hex: "#D9D9D9"))
.aspectRatio(contentMode: .fit)
.font(Font.title.weight(.medium))
.frame(width: 14, height: 14)
}
}
)
.background(Color.clear)
.buttonStyle(PlainButtonStyle())
}
func artwork(_ itemAudioProperties: LinkedItemAudioProperties, forDimensions dim: Double) -> some View {
@ -107,31 +120,25 @@
func playerContent(_ itemAudioProperties: LinkedItemAudioProperties) -> some View {
VStack(spacing: 0) {
HStack(alignment: .center, spacing: 8) {
artwork(itemAudioProperties, forDimensions: 64)
HStack(alignment: .center, spacing: 15) {
artwork(itemAudioProperties, forDimensions: 50)
VStack {
Text(itemAudioProperties.title)
.font(.appCallout)
.foregroundColor(.appGrayTextContrast)
.fixedSize(horizontal: false, vertical: false)
.frame(maxWidth: .infinity, alignment: .leading)
Text(itemAudioProperties.title)
.font(Font.system(size: 17, weight: .medium))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(2)
.foregroundColor(.appGrayTextContrast)
.frame(maxHeight: 40, alignment: .leading)
if let byline = itemAudioProperties.byline {
Text(byline)
.font(.appCaption)
.lineSpacing(1.25)
.foregroundColor(.appGrayText)
.fixedSize(horizontal: false, vertical: false)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Spacer(minLength: 0)
playPauseButtonItem
.frame(width: 28, height: 28)
.frame(width: 40, height: 40)
.foregroundColor(.themeDarkGray)
stopButton
.frame(width: 28, height: 28)
.frame(width: 40, height: 40)
.foregroundColor(.themeDarkGray)
}
.padding(16)
.frame(maxHeight: .infinity)
@ -144,11 +151,10 @@
)
.onTapGesture {
withAnimation(.easeIn(duration: 0.08)) { expanded = true }
}.sheet(isPresented: $expanded) {
}.fullScreenCover(isPresented: $expanded) {
ExpandedPlayer()
}
.offset(y: expanded ? 88 : 0)
.opacity(expanded ? 0.0 : 1.0)
.offset(y: expanded ? 110 : 0)
}
public var body: some View {
@ -157,14 +163,14 @@
if let itemAudioProperties = self.audioController.itemAudioProperties {
ZStack(alignment: .bottom) {
Color.systemBackground.edgesIgnoringSafeArea(.bottom)
.frame(height: expanded ? 0 : 88, alignment: .bottom)
.frame(height: expanded ? 0 : 110, alignment: .bottom)
VStack {
Spacer(minLength: 0)
playerContent(itemAudioProperties)
.frame(maxHeight: expanded ? 0 : 88)
.tint(.appGrayTextContrast)
.background(Color.systemBackground)
.frame(maxHeight: expanded ? 0 : 110)
// .tint(.appGrayTextContrast)
// .background(Color.systemBackground)
}
}
}

View file

@ -3,6 +3,15 @@
import Foundation
import SwiftUI
extension UIColor {
func image(_ size: CGSize = CGSize(width: 1, height: 1)) -> UIImage {
UIGraphicsImageRenderer(size: size).image { rendererContext in
self.setFill()
rendererContext.fill(CGRect(origin: .zero, size: size))
}
}
}
struct ScrubberView: UIViewRepresentable {
typealias UIViewType = UISlider
@ -22,20 +31,25 @@
slider.maximumValue = Float(maxValue)
let tintColor = UIColor(Color.appCtaYellow)
let image = UIImage(systemName: "circle.fill",
withConfiguration: UIImage.SymbolConfiguration(scale: .small))?
let thumbImage = UIImage(systemName: "circle.fill",
withConfiguration: UIImage.SymbolConfiguration(scale: .medium))?
.withTintColor(tintColor)
.withRenderingMode(.alwaysOriginal)
slider.setThumbImage(image, for: .selected)
slider.setThumbImage(image, for: .normal)
slider.setThumbImage(thumbImage, for: .selected)
slider.setThumbImage(thumbImage, for: .normal)
slider.minimumTrackTintColor = tintColor
slider.addTarget(context.coordinator,
action: #selector(Coordinator.valueChanged(_:)),
for: .valueChanged)
// let minImage = UIColor(Color.themeMediumGray).image(CGSize(width: 1, height: 5))
// let maxImage = UIColor(Color.themeLightGray).image(CGSize(width: 1, height: 5))
//
// slider.setMinimumTrackImage(minImage, for: .normal)
// slider.setMaximumTrackImage(maxImage, for: .normal)
return slider
}

View file

@ -179,7 +179,10 @@ struct ProfileView: View {
Alert(
title: Text(LocalText.profileConfirmLogoutMessage),
primaryButton: .destructive(Text(LocalText.genericConfirm)) {
authenticator.logout(dataService: dataService)
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
authenticator.logout(dataService: dataService)
}
},
secondaryButton: .cancel()
)

View file

@ -53,10 +53,9 @@ struct WebReader: PlatformViewRepresentable {
webView.configuration.userContentController = contentController
webView.configuration.userContentController.removeAllScriptMessageHandlers()
webView.uiDelegate
#if os(iOS)
webView.isOpaque = false
webView.backgroundColor = .clear
webView.backgroundColor = Color.isDarkMode ? .systemBackground : .white
webView.scrollView.delegate = context.coordinator
webView.scrollView.contentInset.top = readerViewNavBarHeight
webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight

View file

@ -268,7 +268,7 @@ struct WebReaderContainerView: View {
Image(systemName: "chevron.backward")
.font(.appNavbarIcon)
.foregroundColor(.appGrayTextContrast)
.padding(.horizontal)
.padding()
}
)
.scaleEffect(navBarVisibilityRatio)
@ -391,6 +391,9 @@ struct WebReaderContainerView: View {
if item.isUnread {
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0.1, anchorIndex: 0)
}
Task {
await audioController.preload(itemIDs: [item.unwrappedID])
}
}
.onDisappear {
// if let lastScrollPercentage = self.lastScrollPercentage {
@ -519,7 +522,8 @@ struct WebReaderContainerView: View {
try? WebViewManager.shared().dispatchEvent(.saveReadPosition)
}
.onDisappear {
WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
// WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
WebViewManager.shared().loadHTMLString(WebReaderContent.emptyContent(isDark: Color.isDarkMode), baseURL: nil)
}
}

View file

@ -100,4 +100,70 @@ struct WebReaderContent {
</html>
"""
}
// swiftlint:disable line_length
static func emptyContent(isDark: Bool) -> String {
let themeKey = isDark ? "Gray" : "LightGray"
// let savedAt = "new Date(\(item.unwrappedSavedAt.timeIntervalSince1970 * 1000)).toISOString()"
// let createdAt = "new Date(\(item.unwrappedCreatedAt.timeIntervalSince1970 * 1000)).toISOString()"
// let publishedAt = item.publishDate != nil ? "new Date(\(item.publishDate!.timeIntervalSince1970 * 1000)).toISOString()" : "undefined"
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no' />
<style>
@import url("highlight\(themeKey == "Gray" ? "-dark" : "").css");
</style>
</head>
<body>
<div id="root" />
<div id='_omnivore-htmlContent' style="display: none;">
</div>
<script type="text/javascript">
window.omnivoreEnv = {
"NEXT_PUBLIC_APP_ENV": "prod",
"NEXT_PUBLIC_BASE_URL": "unset",
"NEXT_PUBLIC_SERVER_BASE_URL": "unset",
"NEXT_PUBLIC_HIGHLIGHTS_BASE_URL": "unset"
}
window.omnivoreArticle = {
id: "-1",
linkId: "-1",
slug: "none",
createdAt: new Date(),
savedAt: new Date(),
publishedAt: new Date(),
url: ``,
title: ``,
content: document.getElementById('_omnivore-htmlContent').innerHTML,
originalArticleUrl: "",
contentReader: "WEB",
readingProgressPercent: 0,
readingProgressAnchorIndex: 0,
labels: [],
highlights: [],
recommendations: [],
}
window.fontSize = 0
window.fontFamily = "Inter"
window.maxWidthPercentage = 0
window.lineHeight = 1.25
window.localStorage.setItem("theme", "\(themeKey)")
window.prefersHighContrastFont = true
window.enableHighlightBar = false
window.highlightOnRelease = false
</script>
<script src="bundle.js"></script>
<script src="mathJaxConfiguration.js" id="MathJax-script"></script>
<script src="mathjax.js" id="MathJax-script"></script>
</body>
</html>
"""
}
}

View file

@ -35,6 +35,7 @@ public struct WebReaderLoadingContainer: View {
let requestID: String
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@StateObject var viewModel = WebReaderLoadingContainerViewModel()
public var body: some View {

View file

@ -158,6 +158,7 @@ struct SafariWebLink: Identifiable {
let readingProgress = messageBody["readingProgressPercent"] as? Double
let anchorIndex = messageBody["readingProgressAnchorIndex"] as? Int
print("READING PROGRESS FROM JS: ", messageBody)
guard let itemID = itemID, let readingProgress = readingProgress, let anchorIndex = anchorIndex else {
replyHandler(["result": false], nil)
return

View file

@ -82,6 +82,17 @@
)
}
public var offsets: [Double]? {
if let durations = durations {
var currentSum = 0.0
return durations.map {
currentSum += $0
return currentSum
}
}
return nil
}
public func stop() {
let stoppedId = itemAudioProperties?.itemID
let stoppedTimeElapsed = timeElapsed
@ -143,20 +154,8 @@
}
public func preload(itemIDs: [String], retryCount _: Int = 0) async -> Bool {
if !preloadEnabled {
return true
}
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, speechAuthHeader: speechAuthHeader)
do {
try await synthesizer.preload()
return true
} catch {
print("error preloading audio file", error)
}
}
_ = try? await downloadSpeechFile(itemID: itemID, priority: .low)
}
return false
}
@ -177,6 +176,17 @@
return false
}
public static func removeAudioFiles(itemID: String) {
do {
let audioDirectory = pathForAudioDirectory(itemID: itemID)
try FileManager.default.removeItem(at: audioDirectory)
} catch {
// We don't need to throw here, as its likely the
// directory just doesn't exist
print("Error removing audio files", error)
}
}
public var scrubState: PlayerScrubState = .reset {
didSet {
switch scrubState {
@ -531,14 +541,14 @@
itemID + "-" + currentVoice + ".mp3"
}
public func pathForAudioDirectory(itemID: String) -> URL {
public static func pathForAudioDirectory(itemID: String) -> URL {
FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("audio-\(itemID)/")
}
public func pathForSpeechFile(itemID: String) -> URL {
pathForAudioDirectory(itemID: itemID)
Self.pathForAudioDirectory(itemID: itemID)
.appendingPathComponent("speech-\(currentVoice).json")
}

View file

@ -52,6 +52,10 @@ struct SpeechDocument: Decodable {
}
var audioDirectory: URL {
Self.audioDirectory(pageId: pageId)
}
static func audioDirectory(pageId: String) -> URL {
FileManager.default
.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("audio-\(pageId)")
@ -125,6 +129,7 @@ struct SpeechSynthesizer {
func createPlayerItems(from: Int) -> [SpeechItem] {
var result: [SpeechItem] = []
// TODO: JACKSON
for idx in from ..< document.utterances.count {
let utterance = document.utterances[idx]
let voiceStr = utterance.voice ?? document.defaultVoice

View file

@ -31,6 +31,7 @@ public enum VoiceCategory: String, CaseIterable {
case enUK = "English (UK)"
case deDE = "German (Germany)"
case hiIN = "Hindi (India)"
case itIT = "Italian (Italy)"
case esES = "Spanish (Spain)"
case jaJP = "Japanese (Japan)"
case ptBR = "Portuguese (Brazil)"
@ -69,6 +70,7 @@ public enum Voices {
VoiceLanguage(key: "zh", name: "Chinese", defaultVoice: "zh-CN-XiaochenNeural", categories: [.zhCN]),
VoiceLanguage(key: "de", name: "German", defaultVoice: "de-CH-JanNeural", categories: [.deDE]),
VoiceLanguage(key: "hi", name: "Hindi", defaultVoice: "hi-IN-MadhurNeural", categories: [.hiIN]),
VoiceLanguage(key: "it", name: "Italian", defaultVoice: "it-IT-BenignoNeural", categories: [.itIT]),
VoiceLanguage(key: "ja", name: "Japanese", defaultVoice: "ja-JP-NanamiNeural", categories: [.jaJP]),
VoiceLanguage(key: "es", name: "Spanish", defaultVoice: "es-ES-AlvaroNeural", categories: [.esES]),
VoiceLanguage(key: "pt", name: "Portuguese", defaultVoice: "pt-BR-AntonioNeural", categories: [.ptBR]),
@ -98,12 +100,13 @@ public enum Voices {
VoicePair(firstKey: "ta-IN-PallaviNeural", secondKey: "ta-IN-ValluvarNeural", firstName: "Pallavi", secondName: "Valluvar", language: "ta-IN", category: .taIN),
VoicePair(firstKey: "ta-LK-KumarNeural", secondKey: "ta-LK-SaranyaNeural", firstName: "Kumar", secondName: "Saranya", language: "ta-LK", category: .taLK),
VoicePair(firstKey: "ta-MY-KaniNeural", secondKey: "ta-MY-SuryaNeural", firstName: "Kani", secondName: "Surya", language: "ta-MY", category: .taMY),
VoicePair(firstKey: "ta-SG-AnbuNeural", secondKey: "ta-SG-VenbaNeural", firstName: "Anbu", secondName: "Venba", language: "ta-SG", category: .taSG)
VoicePair(firstKey: "ta-SG-AnbuNeural", secondKey: "ta-SG-VenbaNeural", firstName: "Anbu", secondName: "Venba", language: "ta-SG", category: .taSG),
VoicePair(firstKey: "it-IT-BenignoNeural", secondKey: "it-IT-IsabellaNeural", firstName: "Benigno", secondName: "Isabella", language: "it-IT", category: .itIT)
]
public static let UltraPairs = [
VoicePair(firstKey: "Josh", secondKey: "Rachel", firstName: "Josh", secondName: "Rachel", language: "en-US", category: .enUS),
VoicePair(firstKey: "Arnold", secondKey: "Bella", firstName: "Arnold", secondName: "Bella", language: "en-US", category: .enUS),
VoicePair(firstKey: "Adam", secondKey: "Elli", firstName: "Adam", secondName: "Elli", language: "en-US", category: .enUS)
VoicePair(firstKey: "Josh", secondKey: "Bella", firstName: "Josh", secondName: "Bella", language: "en-US", category: .enUS)
// VoicePair(firstKey: "Arnold", secondKey: "Bella", firstName: "Arnold", secondName: "Bella", language: "en-US", category: .enUS),
// VoicePair(firstKey: "Adam", secondKey: "Elli", firstName: "Adam", secondName: "Elli", language: "en-US", category: .enUS)
]
}

View file

@ -38,6 +38,7 @@ public final class DataService: ObservableObject {
self.networker = networker
self.persistentContainer = PersistentContainer.make()
self.backgroundContext = persistentContainer.newBackgroundContext()
backgroundContext.automaticallyMergesChangesFromParent = true
backgroundContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump

View file

@ -5,10 +5,20 @@ import SwiftGraphQL
public extension DataService {
func removeLink(objectID: NSManagedObjectID) {
// Update CoreData
// First try to get the item synchronously, this is used later to delete files
// Then we can async update core data and make the API call to sync the deletion
var linkedItemID: String?
viewContext.performAndWait {
guard let linkedItem = self.viewContext.object(with: objectID) as? LinkedItem else { return }
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
linkedItemID = linkedItem.id
}
viewContext.perform {
guard let linkedItem = self.viewContext.object(with: objectID) as? LinkedItem else { return }
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsDeletion.rawValue)
linkedItemID = linkedItem.id
do {
try self.viewContext.save()
@ -21,6 +31,12 @@ public extension DataService {
// Send update to server
self.syncLinkDeletion(itemID: linkedItem.unwrappedID)
}
if let linkedItemID = linkedItemID {
Task {
AudioController.removeAudioFiles(itemID: linkedItemID)
}
}
}
func syncLinkDeletion(itemID: String) {

View file

@ -9,6 +9,11 @@ extension DataService {
guard let self = self else { return }
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
if readingProgress != 0, readingProgress < linkedItem.readingProgress {
return
}
print("updating reading progress: ", readingProgress, anchorIndex)
linkedItem.update(
inContext: self.backgroundContext,
newReadingProgress: readingProgress,

View file

@ -22,6 +22,16 @@ public extension Color {
static var indicatorBlue: Color { Color("_indicatorBlue", bundle: .module) }
static var webControlButtonBackground: Color { Color("_webControlButtonBackground", bundle: .module) }
// New theme colors
static var themeLightGray: Color { Color("_lightGray", bundle: .module) }
static var themeMediumGray: Color { Color("_mediumGray", bundle: .module) }
static var themeDarkGray: Color { Color("_darkGray", bundle: .module) }
static var themeGrayBg01: Color { Color("_themeGrayBg01", bundle: .module) }
static var themeHighlightColor: Color { Color("_highlightColor", bundle: .module) }
static var themeTTSReadingText: Color { Color("_themeTTSReadingText", bundle: .module) }
static var themeDisabledBG: Color { Color("_themeDisabledBG", bundle: .module) }
static var themeSolidBackground: Color { Color("_themeSolidBackground", bundle: .module) }
// Apple system UIColor equivalents
#if os(iOS)
static var systemBackground: Color { Color(.systemBackground) }

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x3C",
"green" : "0x3C",
"red" : "0x3C"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xEB",
"green" : "0xEB",
"red" : "0xEB"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xD9",
"green" : "0xD9",
"red" : "0xD9"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xD1",
"green" : "0xF0",
"red" : "0xF9"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x1C",
"green" : "0x3A",
"red" : "0x43"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0x89",
"green" : "0x89",
"red" : "0x89"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF8",
"green" : "0xF8",
"red" : "0xF8"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x1E",
"green" : "0x1C",
"red" : "0x1C"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xF8",
"green" : "0xF8",
"red" : "0xF8"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x2A",
"green" : "0x2A",
"red" : "0x2A"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x2A",
"green" : "0x2A",
"red" : "0x2A"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xFF",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -107,21 +107,21 @@ public struct LibraryItemCard: View {
AnyView(HStack {
Text("\(estimatedReadingTime)")
.font(Font.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: "#898989"))
.foregroundColor(Color.themeMediumGray)
+
Text("\(readingProgress)")
.font(Font.system(size: 11, weight: .medium))
.foregroundColor(isPartiallyRead ? Color.appGreenSuccess : Color(hex: "#898989"))
.foregroundColor(isPartiallyRead ? Color.appGreenSuccess : Color.themeMediumGray)
+
Text("\(highlightsText)")
.font(Font.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: "#898989"))
.foregroundColor(Color.themeMediumGray)
+
Text("\(notesText)")
.font(Font.system(size: 11, weight: .medium))
.foregroundColor(Color(hex: "#898989"))
.foregroundColor(Color.themeMediumGray)
}
.frame(maxWidth: .infinity, alignment: .leading))
}
@ -178,7 +178,7 @@ public struct LibraryItemCard: View {
var byLine: some View {
Text(bylineStr)
.font(Font.system(size: 15, weight: .regular))
.foregroundColor(Color(hex: "#898989"))
.foregroundColor(Color.themeMediumGray)
.frame(maxWidth: .infinity, alignment: .leading)
.lineLimit(1)
}
@ -190,7 +190,7 @@ public struct LibraryItemCard: View {
Text(item.unwrappedTitle)
.font(Font.system(size: 18, weight: .semibold))
.lineSpacing(1.25)
.foregroundColor(isFullyRead ? Color(hex: "#898989") : .appGrayTextContrast)
.foregroundColor(isFullyRead ? Color.themeMediumGray : .appGrayTextContrast)
.fixedSize(horizontal: false, vertical: true)
byLine

View file

@ -50,6 +50,10 @@ public extension Font {
.customFont(InterFont.regular.rawValue, size: 16, relativeTo: .callout)
}
static var appTextToSpeechCurrent: Font {
.customFont(InterFont.medium.rawValue, size: 16, relativeTo: .callout)
}
/// 15pt, Inter-Regular
static var appSubheadline: Font {
.customFont(InterFont.regular.rawValue, size: 15, relativeTo: .subheadline)

File diff suppressed because one or more lines are too long

View file

@ -80,7 +80,7 @@
"texttospeechBetaSignupInProcess" = "Signing up for beta";
"texttospeechBetaRealisticVoiceLimit" = "You are in the ultra realistic voices beta. During the beta you can listen to 10,000 words of audio per day.";
"texttospeechBetaRequestReceived" = "Your request to join the ultra realistic voices demo has been received. You will be informed by email when a spot is available.";
"texttospeechBetaWaitlist" = "Ultra realistic voices are currently in limited beta. Enabling the feature will add you to the beta queue.";
"texttospeechBetaWaitlist" = "Ultra realistic voices are currently in limited beta and available for English only. Enabling the feature will add you to the beta queue.";
// Sign in/up
"registrationNoAccount" = "Don't have an account?";

View file

@ -1,8 +1,5 @@
import { Box } from '../../elements/LayoutPrimitives'
import {
getTopOmnivoreAnchorElement,
useReadingProgressAnchor,
} from '../../../lib/hooks/useReadingProgressAnchor'
import { useReadingProgressAnchor } from '../../../lib/hooks/useReadingProgressAnchor'
import {
ScrollOffsetChangeset,
useScrollWatcher,
@ -18,9 +15,8 @@ import {
import { Tweet } from 'react-twitter-widgets'
import { render } from 'react-dom'
import { isDarkTheme } from '../../../lib/themeUpdater'
import throttle from 'lodash/throttle'
import debounce from 'lodash/debounce'
import { ArticleMutations } from '../../../lib/articleActions'
import { ArticleReadingProgressMutationInput } from '../../../lib/networking/mutations/articleReadingProgressMutation'
export type ArticleProps = {
articleId: string
@ -33,71 +29,53 @@ export type ArticleProps = {
export function Article(props: ArticleProps): JSX.Element {
const highlightTheme = isDarkTheme() ? 'dark' : 'default'
const articleContentRef = useRef<HTMLDivElement | null>(null)
const [readingProgress, setReadingProgress] = useState(
props.initialReadingProgress
)
const [readingAnchorIndex, setReadingAnchorIndex] = useState(
props.initialAnchorIndex
)
const [shouldScrollToInitialPosition, setShouldScrollToInitialPosition] =
useState(true)
const throttledSetReadingProgress = useMemo(
const articleContentRef = useRef<HTMLDivElement | null>(null)
useReadingProgressAnchor(articleContentRef, setReadingAnchorIndex)
const debouncedSetReadingProgress = useMemo(
() =>
throttle(
(readingProgress: number) => {
syncReadingProgress(
props.articleId,
readingProgress,
props.articleMutations.articleReadingProgressMutation
)
setReadingProgress(readingProgress)
},
2000,
{ leading: true }
),
[
props.articleId,
props.articleMutations.articleReadingProgressMutation,
readingProgress,
setReadingProgress,
]
debounce((readingProgress: number) => {
setReadingProgress(readingProgress)
}, 2000),
[]
)
// Flush the invocation of the throttled function when unmounting
// Stop the invocation of the debounced function
// after unmounting
useEffect(() => {
return () => {
throttledSetReadingProgress.flush()
debouncedSetReadingProgress.cancel()
}
}, [readingProgress, setReadingProgress])
}, [])
const syncReadingProgress = (
articleId: string,
readingProgress: number,
mutation: (input: ArticleReadingProgressMutationInput) => Promise<boolean>
) => {
useEffect(() => {
;(async () => {
if (!readingProgress) return
const newAnchorIndex = articleContentRef.current
? getTopOmnivoreAnchorElement(articleContentRef.current)
: undefined
const adjustedReadingProgress = Math.min(
100,
(readingProgress > 0.92 ? 1 : readingProgress) * 100
)
console.log('reading progress: ', readingProgress, newAnchorIndex)
if (newAnchorIndex && !Number.isNaN(Number(newAnchorIndex)))
await mutation({
id: articleId,
// round reading progress to 100% if more than that
readingProgressPercent: adjustedReadingProgress,
readingProgressAnchorIndex: Number(newAnchorIndex),
})
await props.articleMutations.articleReadingProgressMutation({
id: props.articleId,
// round reading progress to 100% if more than that
readingProgressPercent: readingProgress > 100 ? 100 : readingProgress,
readingProgressAnchorIndex: readingAnchorIndex,
})
})()
}
// We don't react to changes to readingAnchorIndex we
// only care about the progress (scroll position) changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.articleId, readingProgress])
// Post message to webkit so apple app embeds get progress updates
// TODO: verify if ios still needs this code...seeems to be duplicated
@ -112,10 +90,10 @@ export function Article(props: ArticleProps): JSX.Element {
useScrollWatcher((changeset: ScrollOffsetChangeset) => {
if (window && window.document.scrollingElement) {
const newReadingProgress =
(window.scrollY + window.innerHeight) /
window.document.scrollingElement.scrollHeight
throttledSetReadingProgress(newReadingProgress)
window.scrollY / window.document.scrollingElement.scrollHeight
const adjustedReadingProgress =
newReadingProgress > 0.92 ? 1 : newReadingProgress
debouncedSetReadingProgress(adjustedReadingProgress * 100)
}
}, 1000)