Merge pull request #3901 from omnivore-app/feat/ios-skip-chapters
iOS Digest Chapters + audio issues
This commit is contained in:
@ -4,31 +4,74 @@ import Services
|
||||
import Views
|
||||
import MarkdownUI
|
||||
import Utils
|
||||
import Transmission
|
||||
|
||||
func getChapterData(digest: DigestResult) -> [(DigestChapter, DigestChapterData)] {
|
||||
let speed = 1.0
|
||||
var chapterData: [(DigestChapter, DigestChapterData)] = []
|
||||
var currentAudioIndex = 0
|
||||
var currentWordCount = 0.0
|
||||
|
||||
for (index, speechFile) in digest.speechFiles.enumerated() {
|
||||
let chapter = digest.chapters[index]
|
||||
let duration = currentWordCount / SpeechDocument.averageWPM / speed * 60.0
|
||||
|
||||
chapterData.append((chapter, DigestChapterData(
|
||||
time: formatTimeInterval(duration) ?? "00:00",
|
||||
start: Int(currentAudioIndex),
|
||||
end: currentAudioIndex + Int(speechFile.utterances.count)
|
||||
)))
|
||||
currentAudioIndex += Int(speechFile.utterances.count)
|
||||
currentWordCount += chapter.wordCount
|
||||
}
|
||||
return chapterData
|
||||
}
|
||||
|
||||
func formatTimeInterval(_ time: TimeInterval) -> String? {
|
||||
let componentFormatter = DateComponentsFormatter()
|
||||
componentFormatter.unitsStyle = .positional
|
||||
componentFormatter.allowedUnits = time >= 3600 ? [.second, .minute, .hour] : [.second, .minute]
|
||||
componentFormatter.zeroFormattingBehavior = .pad
|
||||
return componentFormatter.string(from: time)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public class FullScreenDigestViewModel: ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var digest: DigestResult?
|
||||
@Published var chapterInfo: [(DigestChapter, DigestChapterData)]?
|
||||
@Published var presentedLibraryItem: String?
|
||||
@Published var presentWebContainer = false
|
||||
|
||||
@AppStorage(UserDefaultKey.lastVisitedDigestId.rawValue) var lastVisitedDigestId = ""
|
||||
|
||||
func load(dataService: DataService, audioController: AudioController) async {
|
||||
if let digest = dataService.loadStoredDigest() {
|
||||
self.digest = digest
|
||||
} else {
|
||||
isLoading = true
|
||||
}
|
||||
do {
|
||||
if let digest = try await dataService.getLatestDigest(timeoutInterval: 10) {
|
||||
if !digestNeedsRefresh() {
|
||||
if let digest = dataService.loadStoredDigest() {
|
||||
self.digest = digest
|
||||
lastVisitedDigestId = digest.id
|
||||
if let playingDigest = audioController.itemAudioProperties as? DigestAudioItem, playingDigest.digest.id == digest.id {
|
||||
// Don't think we need to do anything here
|
||||
} else {
|
||||
audioController.play(itemAudioProperties: DigestAudioItem(digest: digest))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("ERROR WITH DIGEST: ", error)
|
||||
} else {
|
||||
do {
|
||||
if let digest = try await dataService.getLatestDigest(timeoutInterval: 10) {
|
||||
self.digest = digest
|
||||
}
|
||||
} catch {
|
||||
print("ERROR WITH DIGEST: ", error)
|
||||
self.digest = nil
|
||||
}
|
||||
}
|
||||
|
||||
if let digest = self.digest {
|
||||
self.digest = digest
|
||||
self.chapterInfo = getChapterData(digest: digest)
|
||||
self.lastVisitedDigestId = digest.id
|
||||
|
||||
if let playingDigest = audioController.itemAudioProperties as? DigestAudioItem, playingDigest.digest.id == digest.id {
|
||||
// Don't think we need to do anything here
|
||||
} else {
|
||||
let chapterData = self.chapterInfo?.map { $0.1 }
|
||||
audioController.play(itemAudioProperties: DigestAudioItem(digest: digest, chapters: chapterData ?? []))
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
@ -41,6 +84,22 @@ public class FullScreenDigestViewModel: ObservableObject {
|
||||
print("ERROR WITH DIGEST: ", error)
|
||||
}
|
||||
}
|
||||
|
||||
func digestNeedsRefresh() -> Bool {
|
||||
let fileManager = FileManager.default
|
||||
let localURL = URL.om_cachesDirectory.appendingPathComponent("digest.json")
|
||||
do {
|
||||
let attributes = try fileManager.attributesOfItem(atPath: localURL.path)
|
||||
if let modificationDate = attributes[.modificationDate] as? Date {
|
||||
// Two hours ago
|
||||
let twoHoursAgo = Date().addingTimeInterval(-2 * 60 * 60)
|
||||
return modificationDate < twoHoursAgo
|
||||
}
|
||||
} catch {
|
||||
print("Error: \(error)")
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
@ -82,10 +141,36 @@ struct FullScreenDigestView: View {
|
||||
return ""
|
||||
}
|
||||
|
||||
var slideTransition: PresentationLinkTransition {
|
||||
PresentationLinkTransition.slide(
|
||||
options: PresentationLinkTransition.SlideTransitionOptions(
|
||||
edge: .trailing,
|
||||
options: PresentationLinkTransition.Options(
|
||||
modalPresentationCapturesStatusBarAppearance: true
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
titleBlock
|
||||
|
||||
if let presentedLibraryItem = self.viewModel.presentedLibraryItem {
|
||||
PresentationLink(
|
||||
transition: slideTransition,
|
||||
isPresented: $viewModel.presentWebContainer,
|
||||
destination: {
|
||||
WebReaderLoadingContainer(requestID: presentedLibraryItem)
|
||||
.background(ThemeManager.currentBgColor)
|
||||
.onDisappear {
|
||||
self.viewModel.presentedLibraryItem = nil
|
||||
}
|
||||
}, label: {
|
||||
EmptyView()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
VStack {
|
||||
@ -114,35 +199,6 @@ struct FullScreenDigestView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
func getChapterData(digest: DigestResult) -> [String:(time: String, start: Int, end: Int)] {
|
||||
let speed = 1.0
|
||||
var chapterData: [String:(time: String, start: Int, end: Int)] = [:]
|
||||
var currentAudioIndex = 0
|
||||
var currentWordCount = 0.0
|
||||
|
||||
for (index, speechFile) in digest.speechFiles.enumerated() {
|
||||
let chapter = digest.chapters[index]
|
||||
let duration = currentWordCount / SpeechDocument.averageWPM / speed * 60.0
|
||||
|
||||
chapterData[chapter.id] = (
|
||||
time: formatTimeInterval(duration) ?? "00:00",
|
||||
start: Int(currentAudioIndex),
|
||||
end: currentAudioIndex + Int(speechFile.utterances.count)
|
||||
)
|
||||
currentAudioIndex += Int(speechFile.utterances.count)
|
||||
currentWordCount += chapter.wordCount
|
||||
}
|
||||
return chapterData
|
||||
}
|
||||
|
||||
func formatTimeInterval(_ time: TimeInterval) -> String? {
|
||||
let componentFormatter = DateComponentsFormatter()
|
||||
componentFormatter.unitsStyle = .positional
|
||||
componentFormatter.allowedUnits = time >= 3600 ? [.second, .minute, .hour] : [.second, .minute]
|
||||
componentFormatter.zeroFormattingBehavior = .pad
|
||||
return componentFormatter.string(from: time)
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
var itemBody: some View {
|
||||
VStack {
|
||||
@ -166,11 +222,6 @@ struct FullScreenDigestView: View {
|
||||
.font(Font.system(size: 12))
|
||||
.foregroundColor(Color(hex: "#898989"))
|
||||
.lineLimit(1)
|
||||
Text(digest.description)
|
||||
.font(Font.system(size: 14))
|
||||
.lineSpacing(/*@START_MENU_TOKEN@*/10.0/*@END_MENU_TOKEN@*/)
|
||||
.foregroundColor(Color.themeLibraryItemSubtle)
|
||||
.lineLimit(6)
|
||||
} else {
|
||||
Text("We're building you a new digest")
|
||||
.font(Font.system(size: 17, weight: .semibold))
|
||||
@ -182,30 +233,35 @@ struct FullScreenDigestView: View {
|
||||
.background(Color.themeLabelBackground.opacity(0.6))
|
||||
.cornerRadius(5)
|
||||
|
||||
if let digest = viewModel.digest {
|
||||
if let chapters = viewModel.chapterInfo {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Chapters")
|
||||
.font(Font.system(size: 17, weight: .semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(0)
|
||||
let chapterData = getChapterData(digest: digest)
|
||||
ForEach(digest.chapters, id: \.id) { chapter in
|
||||
if let startTime = chapterData[chapter.id]?.time, let skipIndex = chapterData[chapter.id]?.start {
|
||||
let currentChapter = audioController.currentAudioIndex >= (chapterData[chapter.id]?.start ?? 0) &&
|
||||
audioController.currentAudioIndex < (chapterData[chapter.id]?.end ?? 0)
|
||||
ChapterView(
|
||||
startTime: startTime,
|
||||
skipIndex: skipIndex,
|
||||
chapter: chapter
|
||||
)
|
||||
.onTapGesture {
|
||||
audioController.seek(toIdx: skipIndex)
|
||||
ForEach(chapters, id: \.0.id) { chaps in
|
||||
let (chapter, chapterData) = chaps
|
||||
let currentChapter = (audioController.currentAudioIndex >= chapterData.start && audioController.currentAudioIndex < chapterData.end)
|
||||
|
||||
ChapterView(
|
||||
startTime: chapterData.time,
|
||||
skipIndex: chapterData.start,
|
||||
chapter: chapter
|
||||
)
|
||||
.onTapGesture {
|
||||
audioController.seek(toIdx: chapterData.start)
|
||||
if audioController.state != .loading && !audioController.isPlaying {
|
||||
audioController.unpause()
|
||||
}
|
||||
.background(
|
||||
currentChapter ? Color.themeLabelBackground.opacity(0.6) : Color.clear
|
||||
)
|
||||
.cornerRadius(5)
|
||||
}
|
||||
.onLongPressGesture {
|
||||
viewModel.presentedLibraryItem = chapter.id
|
||||
viewModel.presentWebContainer = true
|
||||
}
|
||||
.background(
|
||||
currentChapter ? Color.themeLabelBackground.opacity(0.6) : Color.clear
|
||||
)
|
||||
.cornerRadius(5)
|
||||
}
|
||||
}
|
||||
.padding(.top, 20)
|
||||
@ -238,27 +294,27 @@ struct FullScreenDigestView: View {
|
||||
RatingWidget()
|
||||
Spacer(minLength: 60)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("If you didn't like today's digest or would like another one you can create another one. The process takes a few minutes")
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.refreshDigest(dataService: dataService)
|
||||
}
|
||||
}, label: {
|
||||
Text("Create new digest")
|
||||
.font(Font.system(size: 13, weight: .medium))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.tint(Color.blue)
|
||||
.background(Color.themeLabelBackground)
|
||||
.cornerRadius(5)
|
||||
})
|
||||
}
|
||||
.padding(15)
|
||||
.background(Color.themeLabelBackground.opacity(0.6))
|
||||
.cornerRadius(5)
|
||||
|
||||
//
|
||||
// VStack(alignment: .leading, spacing: 20) {
|
||||
// Text("If you didn't like today's digest or would like another one you can create another one. The process takes a few minutes")
|
||||
// Button(action: {
|
||||
// Task {
|
||||
// await viewModel.refreshDigest(dataService: dataService)
|
||||
// }
|
||||
// }, label: {
|
||||
// Text("Create new digest")
|
||||
// .font(Font.system(size: 13, weight: .medium))
|
||||
// .padding(.horizontal, 8)
|
||||
// .padding(.vertical, 5)
|
||||
// .tint(Color.blue)
|
||||
// .background(Color.themeLabelBackground)
|
||||
// .cornerRadius(5)
|
||||
// })
|
||||
// }
|
||||
// .padding(15)
|
||||
// .background(Color.themeLabelBackground.opacity(0.6))
|
||||
// .cornerRadius(5)
|
||||
//
|
||||
}.contentMargins(10, for: .scrollContent)
|
||||
|
||||
Spacer()
|
||||
|
||||
@ -114,6 +114,7 @@ import Views
|
||||
if let appliedFilter = filterState.appliedFilter {
|
||||
let shouldRemoteSearch = forceRemote || items.count < 1 || isRefresh && appliedFilter.shouldRemoteSearch
|
||||
if shouldRemoteSearch {
|
||||
updateFetchController(dataService: dataService, filterState: filterState)
|
||||
await loadSearchQuery(dataService: dataService, filterState: filterState, isRefresh: isRefresh)
|
||||
} else {
|
||||
updateFetchController(dataService: dataService, filterState: filterState)
|
||||
|
||||
@ -38,7 +38,7 @@ enum LoadingBarStyle {
|
||||
@Published var selectedLabels = [LinkedItemLabel]()
|
||||
@Published var negatedLabels = [LinkedItemLabel]()
|
||||
@Published var appliedSort = LinkedItemSort.newest.rawValue
|
||||
|
||||
|
||||
@Published var digestIsUnread = false
|
||||
|
||||
@State var lastMoreFetched: Date?
|
||||
|
||||
@ -323,7 +323,7 @@ struct WebReaderContainerView: View {
|
||||
.padding(.trailing, 5)
|
||||
.popover(isPresented: $showPreferencesPopover) {
|
||||
webPreferencesPopoverView
|
||||
.frame(maxWidth: 400, maxHeight: 475)
|
||||
.frame(width: 400, height: 475)
|
||||
}
|
||||
.formSheet(isPresented: $showPreferencesFormsheet, modalSize: CGSize(width: 400, height: 475)) {
|
||||
webPreferencesPopoverView
|
||||
|
||||
@ -33,16 +33,20 @@ public struct DigestAudioItem: AudioItemProperties {
|
||||
public let digest: DigestResult
|
||||
public let itemID: String
|
||||
public let title: String
|
||||
public let chapters: [DigestChapterData]
|
||||
|
||||
public var byline: String?
|
||||
public var imageURL: URL?
|
||||
public var language: String?
|
||||
public var startIndex: Int = 0
|
||||
public var startOffset: Double = 0.0
|
||||
|
||||
public init(digest: DigestResult) {
|
||||
public init(digest: DigestResult, chapters: [DigestChapterData]) {
|
||||
self.digest = digest
|
||||
self.itemID = digest.id
|
||||
self.title = digest.title
|
||||
self.chapters = chapters
|
||||
|
||||
self.startIndex = 0
|
||||
self.startOffset = 0
|
||||
|
||||
@ -697,7 +701,6 @@ public struct DigestAudioItem: AudioItemProperties {
|
||||
player?.insert(playerItem, after: nil)
|
||||
if player?.items().count == 1, atOffset > 0.0 {
|
||||
playerItem.seek(to: CMTimeMakeWithSeconds(atOffset, preferredTimescale: 600)) { success in
|
||||
print("success seeking to time: ", success)
|
||||
self.fireTimer()
|
||||
}
|
||||
}
|
||||
@ -810,15 +813,16 @@ public struct DigestAudioItem: AudioItemProperties {
|
||||
let percentProgress = timeElapsed / duration
|
||||
let speechIndex = (player?.currentItem as? SpeechPlayerItem)?.speechItem.audioIdx ?? 0
|
||||
let anchorIndex = Int((player?.currentItem as? SpeechPlayerItem)?.speechItem.htmlIdx ?? "") ?? 0
|
||||
|
||||
|
||||
if let itemID = itemAudioProperties?.itemID {
|
||||
dataService.updateLinkReadingProgress(itemID: itemID, readingProgress: percentProgress, anchorIndex: anchorIndex, force: true)
|
||||
}
|
||||
|
||||
if let itemID = itemAudioProperties?.itemID, let player = player, let currentItem = player.currentItem {
|
||||
|
||||
if let itemID = itemAudioProperties?.itemID,
|
||||
let player = player,
|
||||
let currentItem = player.currentItem,
|
||||
itemAudioProperties?.audioItemType == .libraryItem {
|
||||
let currentOffset = CMTimeGetSeconds(currentItem.currentTime())
|
||||
print("updating listening info: ", speechIndex, currentOffset, timeElapsed)
|
||||
|
||||
dataService.updateLinkListeningProgress(itemID: itemID,
|
||||
listenIndex: speechIndex,
|
||||
listenOffset: currentOffset,
|
||||
@ -907,12 +911,61 @@ public struct DigestAudioItem: AudioItemProperties {
|
||||
}
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
|
||||
if let digest = self.itemAudioProperties as? DigestAudioItem {
|
||||
commandCenter.nextTrackCommand.isEnabled = true
|
||||
commandCenter.nextTrackCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
|
||||
if let next = self.nextChapterIndex(chapters: digest.chapters, idx: self.currentAudioIndex) {
|
||||
self.seek(toIdx: next)
|
||||
return .success
|
||||
}
|
||||
return .commandFailed
|
||||
}
|
||||
|
||||
commandCenter.previousTrackCommand.isEnabled = true
|
||||
commandCenter.previousTrackCommand.addTarget { event -> MPRemoteCommandHandlerStatus in
|
||||
if let next = self.prevChapterIndex(chapters: digest.chapters, idx: self.currentAudioIndex) {
|
||||
self.seek(toIdx: next)
|
||||
return .success
|
||||
}
|
||||
return .commandFailed
|
||||
}
|
||||
}
|
||||
|
||||
Task {
|
||||
await downloadAndSetArtwork()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func nextChapterIndex(chapters: [DigestChapterData], idx: Int) -> Int? {
|
||||
if let chapterIdx = currentChapterIndex(chapters: chapters, idx: idx) {
|
||||
if chapterIdx + 1 < chapters.count {
|
||||
return chapters[chapterIdx + 1].start
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prevChapterIndex(chapters: [DigestChapterData], idx: Int) -> Int? {
|
||||
if let chapterIdx = currentChapterIndex(chapters: chapters, idx: idx) {
|
||||
if chapterIdx - 1 > 0 {
|
||||
return chapterIdx - 1
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentChapterIndex(chapters: [DigestChapterData], idx: Int) -> Int? {
|
||||
for (chapterIdx, chapter) in chapters.enumerated() {
|
||||
if idx >= chapter.start && idx < chapter.end {
|
||||
if chapterIdx + 1 < chapters.count {
|
||||
return chapterIdx
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isoLangForCurrentVoice() -> String {
|
||||
// currentVoicePair should not ever be nil but if it is we return an empty string
|
||||
if let isoLang = currentVoicePair?.language {
|
||||
@ -965,9 +1018,7 @@ public struct DigestAudioItem: AudioItemProperties {
|
||||
throw BasicError.message(messageText: "audioFetch failed. no data received.")
|
||||
}
|
||||
|
||||
let str = String(decoding: data, as: UTF8.self)
|
||||
print("result speech file: ", str)
|
||||
|
||||
let str = String(decoding: data, as: UTF8.self)
|
||||
if let document = try? JSONDecoder().decode(SpeechDocument.self, from: data) {
|
||||
do {
|
||||
try? FileManager.default.createDirectory(at: document.audioDirectory, withIntermediateDirectories: true)
|
||||
|
||||
@ -70,11 +70,9 @@ class SpeechPlayerItem: AVPlayerItem {
|
||||
DispatchQueue.main.async {
|
||||
if self.speechItem.audioIdx > self.session.currentAudioIndex + 5 {
|
||||
// prefetch has gotten too far ahead of the audio. Pause the prefetch queue
|
||||
print("PAUSING PREFETCH QUEUE", self.speechItem.audioIdx, self.session.currentAudioIndex + 10, self.speechItem.text)
|
||||
prefetchQueue.isSuspended = true
|
||||
}
|
||||
if self.speechItem.audioIdx < self.session.currentAudioIndex + 5 {
|
||||
print("RESUMING PREFETCH QUEUE", self.speechItem.audioIdx, self.session.currentAudioIndex + 5)
|
||||
prefetchQueue.isSuspended = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,6 +36,18 @@ public struct DigestChapter: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct DigestChapterData {
|
||||
public let time: String
|
||||
public let start: Int
|
||||
public let end: Int
|
||||
|
||||
public init(time: String, start: Int, end: Int) {
|
||||
self.time = time
|
||||
self.start = start
|
||||
self.end = end
|
||||
}
|
||||
}
|
||||
|
||||
public struct RefreshDigestResult: Codable {
|
||||
public let jobId: String
|
||||
}
|
||||
|
||||
@ -175,6 +175,15 @@ public struct InternalFilter: Encodable, Identifiable, Hashable, Equatable {
|
||||
position: 10,
|
||||
defaultFilter: true
|
||||
),
|
||||
InternalFilter(
|
||||
id: "following_unread",
|
||||
name: "Unread",
|
||||
folder: "following",
|
||||
filter: "in:following is:unread",
|
||||
visible: true,
|
||||
position: 11,
|
||||
defaultFilter: true
|
||||
),
|
||||
InternalFilter(
|
||||
id: "rss",
|
||||
name: "Feeds",
|
||||
|
||||
@ -55,7 +55,7 @@ func savedDateString(_ savedAt: Date?) -> String {
|
||||
dateFormatter.dateFormat = "MMM dd"
|
||||
}
|
||||
dateFormatter.locale = locale
|
||||
return dateFormatter.string(from: savedAt) + " • "
|
||||
return dateFormatter.string(from: savedAt)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -160,12 +160,19 @@ public struct LibraryItemCard: View {
|
||||
var estimatedReadingTime: String {
|
||||
if item.wordsCount > 0 {
|
||||
let readLen = max(1, item.wordsCount / readingSpeed)
|
||||
return "\(readLen) MIN READ • "
|
||||
return "\(readLen) MIN READ"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var readingProgress: String {
|
||||
if item.readingProgress < 2 {
|
||||
return ""
|
||||
}
|
||||
var readingProgress = item.readingProgress
|
||||
if readingProgress > 95 {
|
||||
readingProgress = 100
|
||||
}
|
||||
// If there is no wordsCount don't show progress because it will make no sense
|
||||
if item.wordsCount > 0 {
|
||||
return "\(String(format: "%d", Int(item.readingProgress)))%"
|
||||
@ -181,18 +188,15 @@ public struct LibraryItemCard: View {
|
||||
item.wordsCount > 0 || item.highlights?.first { ($0 as? Highlight)?.annotation != nil } != nil
|
||||
}
|
||||
|
||||
var highlightsText: String {
|
||||
var highlightsStr: String {
|
||||
if let highlights = item.highlights, highlights.count > 0 {
|
||||
let fmted = LocalText.pluralizedText(key: "number_of_highlights", count: highlights.count)
|
||||
if item.wordsCount > 0 || item.isPDF {
|
||||
return " • \(fmted)"
|
||||
}
|
||||
return fmted
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var notesText: String {
|
||||
var notesStr: String {
|
||||
let notes = item.highlights?.filter { item in
|
||||
if let highlight = item as? Highlight {
|
||||
return !(highlight.annotation ?? "").isEmpty
|
||||
@ -202,9 +206,6 @@ public struct LibraryItemCard: View {
|
||||
|
||||
if let notes = notes, notes.count > 0 {
|
||||
let fmted = LocalText.pluralizedText(key: "number_of_notes", count: notes.count)
|
||||
if hasMultipleInfoItems {
|
||||
return " • \(fmted)"
|
||||
}
|
||||
return fmted
|
||||
}
|
||||
return ""
|
||||
@ -228,35 +229,66 @@ public struct LibraryItemCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
var savedAtText: Text? {
|
||||
if !savedAtStr.isEmpty {
|
||||
return Text(savedAtStr)
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color.themeLibraryItemSubtle)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var estimatedReadingTimeText: Text? {
|
||||
if !estimatedReadingTime.isEmpty {
|
||||
return Text("\(estimatedReadingTime)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color.themeLibraryItemSubtle)
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var readingProgressText: Text? {
|
||||
if !readingProgress.isEmpty {
|
||||
return Text("\(readingProgress)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(isPartiallyRead ? Color.appGreenSuccess : Color.themeLibraryItemSubtle)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var highlightsText: Text? {
|
||||
if !highlightsStr.isEmpty {
|
||||
return Text("\(highlightsStr)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color.themeLibraryItemSubtle)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var notesText: Text? {
|
||||
if !notesStr.isEmpty {
|
||||
return Text("\(notesStr)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color.themeLibraryItemSubtle)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var readInfo: some View {
|
||||
HStack(alignment: .center, spacing: 5.0) {
|
||||
ForEach(flairLabels, id: \.self) {
|
||||
$0.icon
|
||||
}
|
||||
|
||||
Text(savedAtStr)
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color.themeLibraryItemSubtle)
|
||||
let texts = [savedAtText, estimatedReadingTimeText, readingProgressText, highlightsText, notesText]
|
||||
.compactMap { $0 }
|
||||
|
||||
+
|
||||
Text("\(estimatedReadingTime)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color.themeLibraryItemSubtle)
|
||||
|
||||
+
|
||||
Text("\(readingProgress)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(isPartiallyRead ? Color.appGreenSuccess : Color.themeLibraryItemSubtle)
|
||||
|
||||
+
|
||||
Text("\(highlightsText)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color.themeLibraryItemSubtle)
|
||||
|
||||
+
|
||||
Text("\(notesText)")
|
||||
.font(.footnote)
|
||||
.foregroundColor(Color.themeLibraryItemSubtle)
|
||||
if texts.count > 0 {
|
||||
texts.dropLast().reduce(Text("")) { result, text in
|
||||
result + text + Text(" • ").font(.footnote).foregroundColor(Color.themeLibraryItemSubtle)
|
||||
} + texts.last!
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ -216,7 +216,9 @@ public enum WebFont: String, CaseIterable {
|
||||
updateReaderPreferences()
|
||||
|
||||
}, label: { Image(systemName: "textformat.size.smaller") })
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 25, height: 25, alignment: .center)
|
||||
.foregroundColor(.appGrayTextContrast.opacity(0.4))
|
||||
CustomSlider(value: $storedFontSize, minValue: 10, maxValue: 48) { _ in
|
||||
if storedFontSize % 1 == 0 {
|
||||
updateReaderPreferences()
|
||||
@ -230,7 +232,9 @@ public enum WebFont: String, CaseIterable {
|
||||
updateReaderPreferences()
|
||||
|
||||
}, label: { Image(systemName: "textformat.size.larger") })
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 25, height: 25, alignment: .center)
|
||||
.foregroundColor(.appGrayTextContrast.opacity(0.4))
|
||||
}
|
||||
}
|
||||
|
||||
@ -244,7 +248,9 @@ public enum WebFont: String, CaseIterable {
|
||||
updateReaderPreferences()
|
||||
|
||||
}, label: { Image("margin-smaller", bundle: .module) })
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 25, height: 25, alignment: .center)
|
||||
.foregroundColor(.appGrayTextContrast.opacity(0.4))
|
||||
CustomSlider(value: $storedMaxWidthPercentage, minValue: minValue, maxValue: 100) { _ in
|
||||
updateReaderPreferences()
|
||||
}
|
||||
@ -256,7 +262,9 @@ public enum WebFont: String, CaseIterable {
|
||||
updateReaderPreferences()
|
||||
|
||||
}, label: { Image("margin-larger", bundle: .module) })
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 25, height: 25, alignment: .center)
|
||||
.foregroundColor(.appGrayTextContrast.opacity(0.4))
|
||||
}
|
||||
}
|
||||
|
||||
@ -267,7 +275,9 @@ public enum WebFont: String, CaseIterable {
|
||||
updateReaderPreferences()
|
||||
|
||||
}, label: { Image("lineheight-smaller", bundle: .module) })
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 25, height: 25, alignment: .center)
|
||||
.foregroundColor(.appGrayTextContrast.opacity(0.4))
|
||||
CustomSlider(value: $storedLineSpacing, minValue: 100, maxValue: 300) { _ in
|
||||
updateReaderPreferences()
|
||||
}
|
||||
@ -279,7 +289,9 @@ public enum WebFont: String, CaseIterable {
|
||||
updateReaderPreferences()
|
||||
|
||||
}, label: { Image("lineheight-larger", bundle: .module) })
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 25, height: 25, alignment: .center)
|
||||
.foregroundColor(.appGrayTextContrast.opacity(0.4))
|
||||
}
|
||||
}
|
||||
|
||||
@ -288,7 +300,9 @@ public enum WebFont: String, CaseIterable {
|
||||
Button(action: {
|
||||
storedFontSize = min(storedFontSize + 2, 28)
|
||||
}, label: { Image("brightness-lower", bundle: .module) })
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 25, height: 25, alignment: .center)
|
||||
.foregroundColor(.appGrayTextContrast.opacity(0.4))
|
||||
CustomSlider(value: $storedFontSize, minValue: 10, maxValue: 28) { _ in
|
||||
updateReaderPreferences()
|
||||
}
|
||||
@ -297,7 +311,9 @@ public enum WebFont: String, CaseIterable {
|
||||
Button(action: {
|
||||
storedFontSize = max(storedFontSize - 2, 10)
|
||||
}, label: { Image("brightness-higher", bundle: .module) })
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 25, height: 25, alignment: .center)
|
||||
.foregroundColor(.appGrayTextContrast.opacity(0.4))
|
||||
}
|
||||
}
|
||||
|
||||
@ -371,6 +387,7 @@ public enum WebFont: String, CaseIterable {
|
||||
Text("Margin")
|
||||
.font(Font.system(size: 14))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
|
||||
marginSlider
|
||||
.padding(.top, 5)
|
||||
@ -379,6 +396,7 @@ public enum WebFont: String, CaseIterable {
|
||||
Text("Line Height")
|
||||
.font(Font.system(size: 14))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
|
||||
lineHeightSlider
|
||||
// .padding(.bottom, 20)
|
||||
@ -390,7 +408,7 @@ public enum WebFont: String, CaseIterable {
|
||||
//
|
||||
// brightnessSlider
|
||||
|
||||
}.tint(Color(hex: "#6A6968"))
|
||||
}.tint(.appGrayTextContrast)
|
||||
.padding(.horizontal, 30)
|
||||
|
||||
Divider()
|
||||
@ -401,6 +419,7 @@ public enum WebFont: String, CaseIterable {
|
||||
Text("Theme")
|
||||
.font(Font.system(size: 14))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
Spacer()
|
||||
|
||||
systemThemeCheckbox
|
||||
|
||||
@ -27,6 +27,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
enum OmnivoreIntentError: Swift.Error, CustomLocalizedStringResourceConvertible {
|
||||
case general
|
||||
case message(_ message: String)
|
||||
|
||||
var localizedStringResource: LocalizedStringResource {
|
||||
switch self {
|
||||
case let .message(message): return "Error: \(message)"
|
||||
case .general: return "My general error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct LibraryItemEntity: AppEntity {
|
||||
static var defaultQuery = LibraryItemQuery()
|
||||
@ -108,15 +122,36 @@
|
||||
var link: URL
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog {
|
||||
do {
|
||||
let requestId = UUID().uuidString.lowercased()
|
||||
_ = try? await Services().dataService.saveURL(id: requestId, url: link.absoluteString)
|
||||
return .result(dialog: "Link saved to Omnivore")
|
||||
} catch {
|
||||
print("error saving URL: ", error)
|
||||
func perform() async throws -> some IntentResult & ProvidesDialog & ReturnsValue<URL> {
|
||||
let requestId = UUID().uuidString.lowercased()
|
||||
let result = try? await Services().dataService.saveURL(id: requestId, url: link.absoluteString)
|
||||
if let result = result, let deepLink = URL(string: "omnivore://read/\(result)") {
|
||||
return .result(value: deepLink, dialog: "Link saved")
|
||||
}
|
||||
return .result(dialog: "Error saving link")
|
||||
throw OmnivoreIntentError.message("Unable to save link")
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
struct SaveToOmnivoreAndReturnDeeplinkIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Save to Omnivore"
|
||||
static var description: LocalizedStringResource = "Save a URL to your Omnivore library"
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
Summary("Save \(\.$link) to your Omnivore library.")
|
||||
}
|
||||
|
||||
@Parameter(title: "link")
|
||||
var link: URL
|
||||
|
||||
@MainActor
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<URL> {
|
||||
let requestId = UUID().uuidString.lowercased()
|
||||
let result = try? await Services().dataService.saveURL(id: requestId, url: link.absoluteString)
|
||||
if let result = result, let deepLink = URL(string: "omnivore://read/\(result)") {
|
||||
return .result(value: deepLink)
|
||||
}
|
||||
throw OmnivoreIntentError.message("Unable to save link")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user