mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Update TTS user interface
This commit is contained in:
parent
40567a6ed9
commit
c9296deac4
36 changed files with 803 additions and 216 deletions
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -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?";
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue