From 47150c29913ecece27a755509815eecc8642bd17 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 16 Apr 2024 13:34:12 +0800 Subject: [PATCH] Refactoring the UX for digest view --- .../App/Views/AI/FullScreenDigestView.swift | 303 ++++++++++++++++++ .../App/Views/AI/LibraryDigestView.swift | 267 --------------- .../AudioPlayer/ExpandedAudioPlayer.swift | 22 +- .../App/Views/AudioPlayer/MiniPlayer.swift | 4 +- .../Views/AudioPlayer/MiniPlayerViewer.swift | 12 +- .../App/Views/Home/HomeFeedViewIOS.swift | 73 +++-- .../Sources/App/Views/LibraryTabView.swift | 32 +- .../App/Views/PrimaryContentView.swift | 2 +- .../Views/WebReader/WebReaderContainer.swift | 20 +- .../Sources/Models/DataModels/FeedItem.swift | 39 ++- .../AudioSession/AudioController.swift | 78 ++++- .../AudioSession/SpeechSynthesizer.swift | 4 +- .../Services/DataService/AI/AITasks.swift | 118 +++++++ 13 files changed, 608 insertions(+), 366 deletions(-) create mode 100644 apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift delete mode 100644 apple/OmnivoreKit/Sources/App/Views/AI/LibraryDigestView.swift create mode 100644 apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift diff --git a/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift b/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift new file mode 100644 index 000000000..d2472344f --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/AI/FullScreenDigestView.swift @@ -0,0 +1,303 @@ +import SwiftUI +import Models +import Services + +public class FullScreenDigestViewModel: ObservableObject { + @Published var isLoading = false + @Published var digest: DigestResult? + + func load(dataService: DataService) async { + isLoading = true + if digest == nil { + do { + digest = try await dataService.getLatestDigest(timeoutInterval: 10) + } catch { + print("ERROR WITH DIGEST: ", error) + } + } + isLoading = false + } +} + +struct DigestAudioItem: AudioItemProperties { + let audioItemType = Models.AudioItemType.digest + + var itemID = "" + + var title = "TITLE" + + var byline: String? = "byline" + + var imageURL: URL? = nil + + var language: String? + + var startIndex: Int = 0 + var startOffset: Double = 0.0 +} + +@available(iOS 17.0, *) +@MainActor +struct FullScreenDigestView: View { + let viewModel: DigestViewModel = DigestViewModel() + let dataService: DataService + let audioController: AudioController + + @Environment(\.dismiss) private var dismiss + + let textBody = "In a significant political turn, the SOTU response faces unexpected collapse, " + + "marking a stark contrast to Trump's latest downturn, alongside an unprecedented " + + "surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " + + "The analysis provides insights into the shifting dynamics of political support and " + + "the potential implications for future electoral strategies. Based on the information " + + "you provided, the video seems to discuss a recent event where former President " + + "Donald Trump made a controversial statement that shocked even his own audience. " + + "The video likely covers Trump's response to the State of the Union (SOTU) address " + + "and how it received negative feedback, possibly leading to a decline in his support " + + "or approval ratings. Additionally, it appears that the video touches upon a surge " + + "in fundraising for President Joe Biden's administration around March 11, 2024." + + public init(dataService: DataService, audioController: AudioController) { + self.dataService = dataService + self.audioController = audioController + } + + var body: some View { + // ZStack(alignment: Alignment(horizontal: .trailing, vertical: .top)) { + Group { + if viewModel.isLoading { + ProgressView() + } else { + itemBody + .task { + await viewModel.load(dataService: dataService) + }.onAppear { + self.audioController.play(itemAudioProperties: DigestAudioItem()) + } + } + } .navigationTitle("Omnivore digest") + .navigationBarTitleDisplayMode(.inline) + +// HStack(alignment: .top) { +// Spacer() +// closeButton +// } +// .padding(20) + // } + } + + var closeButton: some View { + Button(action: { + dismiss() + }, label: { + ZStack { + Circle() + .foregroundColor(Color.appGrayText) + .frame(width: 36, height: 36) + .opacity(0.1) + + Image(systemName: "xmark") + .font(.appCallout) + .frame(width: 36, height: 36) + } + }) + .buttonStyle(.plain) + } + + @available(iOS 17.0, *) + var itemBody: some View { + VStack { + ScrollView(.vertical) { + VStack(spacing: 20) { + Text("SOTU response collapses, Trump hits new low, Biden fundraising explodes 3/11/24 TDPS Podcast") + .font(.title) + Text(textBody) + .font(.body) + } + } + // .scrollTargetBehavior(.paging) + // .ignoresSafeArea() + MiniPlayerViewer() + .padding(.top, 10) + .padding(.bottom, 40) + .background(Color.themeTabBarColor) + .onTapGesture { + // showExpandedAudioPlayer = true + } + } + } +} + +@MainActor +public class PreviewItemViewModel: ObservableObject { + let dataService: DataService + @Published var item: DigestItem + let showSwipeHint: Bool + + @Published var isLoading = false + @Published var resultText: String? + @Published var promptDisplayText: String? + + init(dataService: DataService, item: DigestItem, showSwipeHint: Bool) { + self.dataService = dataService + self.item = item + self.showSwipeHint = showSwipeHint + } + + func loadResult() async { +// isLoading = true +// let taskId = try? await dataService.createAITask( +// extraText: extraText, +// libraryItemId: item?.id ?? "", +// promptName: "summarize-001" +// ) +// +// if let taskId = taskId { +// do { +// let fetchedText = try await dataService.pollAITask(jobId: taskId, timeoutInterval: 30) +// resultText = fetchedText +// } catch { +// print("ERROR WITH RESULT TEXT: ", error) +// } +// } else { +// print("NO TASK ID: ", taskId) +// } +// isLoading = false + } +} + +@MainActor +struct PreviewItemView: View { + @StateObject var viewModel: PreviewItemViewModel + + var body: some View { + VStack(spacing: 10) { + HStack { + AsyncImage(url: viewModel.item.siteIcon) { phase in + if let image = phase.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 20, height: 20, alignment: .center) + } else { + Color.appButtonBackground + .frame(width: 20, height: 20, alignment: .center) + } + } + Text(viewModel.item.site) + .font(Font.system(size: 14)) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .padding(.top, 10) + Text(viewModel.item.title) + // .font(.body) + // .fontWeight(.semibold) + .font(Font.system(size: 18, weight: .semibold)) + .frame(maxWidth: .infinity, alignment: .topLeading) + + Text(viewModel.item.author) + .font(Font.system(size: 14)) + .foregroundColor(Color(hex: "898989")) + .frame(maxWidth: .infinity, alignment: .topLeading) + + Color(hex: "2A2A2A") + .frame(height: 1) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 20) + + if viewModel.isLoading { + ProgressView() + .task { + await viewModel.loadResult() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Text(viewModel.item.summaryText) + .font(Font.system(size: 16)) + // .font(.body) + .lineSpacing(12.0) + .frame(maxWidth: .infinity, alignment: .topLeading) + HStack { + Button(action: {}, label: { + HStack(alignment: .center) { + Text("Start listening") + .font(Font.system(size: 14)) + .frame(height: 42, alignment: .center) + Image(systemName: "play.fill") + .resizable() + .frame(width: 10, height: 10) + } + .padding(.horizontal, 15) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(18) + }) + Spacer() + } + .padding(.top, 20) + } + Spacer() + if viewModel.showSwipeHint { + VStack { + Image.doubleChevronUp + Text("Swipe up for next article") + .foregroundColor(Color(hex: "898989")) + } + .padding(.bottom, 50) + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 100) + .padding(.horizontal, 15) + + } +} + +struct RatingView: View { + @State private var rating: Int = 0 + + var body: some View { + VStack(spacing: 30) { + Text("Rate today's digest") + .font(.title) + .padding(.vertical, 40) + Text("I liked the stories picked for today's digest") + RatingWidget() + + Text("The stories were interesting") + RatingWidget() + + Text("The voices sounded good") + RatingWidget() + + Text("I liked the music") + RatingWidget() + Spacer() + }.padding(.top, 60) + } +} + +struct StarView: View { + var isFilled: Bool + var body: some View { + Image(systemName: isFilled ? "star.fill" : "star") + .foregroundColor(isFilled ? Color.yellow : Color.gray) + } +} + +struct RatingWidget: View { + @State private var rating: Int = 0 + var body: some View { + HStack { + ForEach(1...5, id: \.self) { index in + StarView(isFilled: index <= rating) + .onTapGesture { + rating = index + } + } + } + .padding() + .background(Color(hex: "313131")) + .cornerRadius(8) + // .shadow(radius: 3) + } +} diff --git a/apple/OmnivoreKit/Sources/App/Views/AI/LibraryDigestView.swift b/apple/OmnivoreKit/Sources/App/Views/AI/LibraryDigestView.swift deleted file mode 100644 index e6f4f91d7..000000000 --- a/apple/OmnivoreKit/Sources/App/Views/AI/LibraryDigestView.swift +++ /dev/null @@ -1,267 +0,0 @@ -import SwiftUI -import Models -import Services - - -struct DigestItem { - let id: String - let site: String - let siteIcon: URL? - let author: String - let title: String - let summaryText: String - let keyPointsText: String - let highlightsText: String -} - -@available(iOS 17.0, *) -@MainActor -struct LibraryDigestView: View { - let dataService: DataService - // @State private var currentIndex = 0 - @State private var items: [DigestItem] - // @State private var preloadedItems: [Int: String] = [:] - @Environment(\.dismiss) private var dismiss - - // let itemCount = 10 // Number of items to initially load - // let prefetchCount = 2 // Number of items to prefetch - - public init(dataService: DataService) { - self.dataService = dataService - self.items = [ - DigestItem( - id: "1468AFAA-88sdfsdfC-4546-BE02-EACF385288FC", - site: "CNBC.com", - siteIcon: URL(string: "https://www.cnbc.com/favicon.ico"), - author: "Kif Leswing", - title: "Apple shares just had their best day since last May", - summaryText: "In a significant political turn, the SOTU response faces unexpected collapse, marking a stark contrast to Trump's latest" + - " downturn, alongside an unprecedented surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " + - "The analysis provides insights into the shifting dynamics of political support and the potential implications for future " + - "electoral strategies. ", - keyPointsText: "Key points from the article:", - highlightsText: "Highlights from the article:" - ), - DigestItem( - id: "1468AFAA-8sdfsdffsdf-4546-BE02-EACF385288FC", - site: "CNBC.com", - siteIcon: URL(string: "https://www.cnbc.com/favicon.ico"), - author: "Kif Leswing", - title: "Apple shares just had their best day since last May", - summaryText: "In a significant political turn, the SOTU response faces unexpected collapse, marking a stark contrast to Trump's latest" + - " downturn, alongside an unprecedented surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " + - "The analysis provides insights into the shifting dynamics of political support and the potential implications for future " + - "electoral strategies. ", - keyPointsText: "Key points from the article:", - highlightsText: "Highlights from the article:" - ), - DigestItem( - id: "1468AFAA-882C-asdadfsa85288FC", - site: "CNBC.com", - siteIcon: URL(string: "https://www.cnbc.com/favicon.ico"), - author: "Kif Leswing", - title: "Apple shares just had their best day since last May", - summaryText: "In a significant political turn, the SOTU response faces unexpected collapse, marking a stark contrast to Trump's latest" + - " downturn, alongside an unprecedented surge in Biden's fundraising efforts as of 3/11/24, according to the TDPS Podcast. " + - "The analysis provides insights into the shifting dynamics of political support and the potential implications for future " + - "electoral strategies. ", - keyPointsText: "Key points from the article:", - highlightsText: "Highlights from the article:" - ) - ] - // currentIndex = 0 - // _preloadedItems = [Int:String] - } - - var body: some View { - itemBody - } - - @available(iOS 17.0, *) - var itemBody: some View { - ScrollView(.vertical) { - LazyVStack(spacing: 0) { - ForEach(Array(self.items.enumerated()), id: \.1.id) { idx, item in - PreviewItemView( - viewModel: PreviewItemViewModel(dataService: dataService, item: item, showSwipeHint: idx == 0) - ) - .containerRelativeFrame([.horizontal, .vertical]) - } - } - .scrollTargetLayout() - } - .scrollTargetBehavior(.paging) - .ignoresSafeArea() - - - // ScrollView(.horizontal, showsIndicators: false) { - // HStack(spacing: 0) { - // ForEach(items.indices, id: \.self) { index in - // if let item = preloadedItems[index] { - // ItemView(content: item) - // .onAppear { - // if index == items.count - prefetchCount { - // fetchItems() - // } - // } - // } - // } - // } - // } - // .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { - // fetchItems() - } - .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in - // Pause any background tasks if necessary - } - - } - -// private func fetchItems() { -// // Simulate fetching items from an API -// for idx in currentIndex.. some View { - if let imageURL = itemAudioProperties.imageURL { + func artwork(_ itemAudioProperties: AudioItemProperties?, forDimensions dim: Double) -> some View { + if let imageURL = itemAudioProperties?.imageURL { return AnyView(AsyncImage(url: imageURL) { phase in if let image = phase.image { image @@ -125,9 +123,9 @@ Text("There was an error playing back your audio.").foregroundColor(Color.red).font(.footnote) Spacer(minLength: 0) } else { - artwork(itemAudioProperties, forDimensions: 50) + artwork(audioController.itemAudioProperties, forDimensions: 50) - Text(itemAudioProperties.title) + Text(audioController.itemAudioProperties?.title ?? "") .font(Font.system(size: 17, weight: .medium)) .fixedSize(horizontal: false, vertical: true) .lineLimit(2) diff --git a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift index 52ae844be..3b22ac24e 100644 --- a/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift +++ b/apple/OmnivoreKit/Sources/App/Views/Home/HomeFeedViewIOS.swift @@ -204,6 +204,9 @@ struct AnimatingCellHeight: AnimatableModifier { @ObservedObject var viewModel: HomeFeedViewModel @State private var selection = Set() + @AppStorage("LibraryList::digestEnabled") var digestEnabled = false + @AppStorage("LibraryList::hasCheckedForDigestFeature") var hasCheckedForDigestFeature = false + init(viewModel: HomeFeedViewModel, isEditMode: Binding) { _viewModel = ObservedObject(wrappedValue: viewModel) _isEditMode = isEditMode @@ -225,7 +228,7 @@ struct AnimatingCellHeight: AnimatableModifier { } var body: some View { - ZStack { + ZStack { HomeFeedView( listTitle: $listTitle, isListScrolled: $isListScrolled, @@ -265,8 +268,8 @@ struct AnimatingCellHeight: AnimatableModifier { VStack(spacing: 0) { Spacer() - if let audioProperties = audioController.itemAudioProperties { - MiniPlayerViewer(itemAudioProperties: audioProperties) + if audioController.itemAudioProperties != nil { + MiniPlayerViewer() .padding(.top, 10) .padding(.bottom, 20) .background(Color.themeTabBarColor) @@ -317,6 +320,15 @@ struct AnimatingCellHeight: AnimatableModifier { } ) } + .fullScreenCover(isPresented: $showLibraryDigest) { + if #available(iOS 17.0, *) { + NavigationView { + FullScreenDigestView(dataService: dataService, audioController: audioController) + } + } else { + Text("Sorry digest is only available on iOS 17 and above") + } + } .toolbar { toolbarItems } @@ -339,6 +351,20 @@ struct AnimatingCellHeight: AnimatableModifier { viewModel.stopUsingFollowingPrimer = true } } + .task { + do { + if let viewer = try await dataService.fetchViewer() { + digestEnabled = viewer.digestEnabled ?? false + if !hasCheckedForDigestFeature { + hasCheckedForDigestFeature = true + // selectedTab = "digest" + } + } + } catch { + print("ERROR FETCHING VIEWER: ", error) + print("") + } + } .environment(\.editMode, self.$isEditMode) .navigationBarTitleDisplayMode(.inline) } @@ -383,15 +409,14 @@ struct AnimatingCellHeight: AnimatableModifier { if isEditMode == .active { Button(action: { isEditMode = .inactive }, label: { Text("Cancel") }) } else { - -// if #available(iOS 17.0, *) { -// Button( -// action: { showLibraryDigest = true }, -// label: { Image(systemName: "sparkles") } -// ) -// .buttonStyle(.plain) -// .padding(.trailing, 4) -// } + if #available(iOS 17.0, *) { + Button( + action: { showLibraryDigest = true }, + label: { Image.tabDigestSelected } + ) + .buttonStyle(.plain) + .padding(.trailing, 4) + } if prefersListLayout { Button( action: { isEditMode = isEditMode == .active ? .inactive : .active }, @@ -481,12 +506,12 @@ struct AnimatingCellHeight: AnimatableModifier { let showFeatureCards: Bool var slideTransition: PresentationLinkTransition { PresentationLinkTransition.slide( - options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing, - options: - PresentationLinkTransition.Options( - modalPresentationCapturesStatusBarAppearance: true - ) - )) + options: PresentationLinkTransition.SlideTransitionOptions( + edge: .trailing, + options: PresentationLinkTransition.Options( + modalPresentationCapturesStatusBarAppearance: true + ) + )) } var body: some View { @@ -494,12 +519,12 @@ struct AnimatingCellHeight: AnimatableModifier { if let linkRequest = viewModel.linkRequest, viewModel.currentListConfig?.hasReadNowSection ?? false { PresentationLink( transition: PresentationLinkTransition.slide( - options: PresentationLinkTransition.SlideTransitionOptions(edge: .trailing, - options: - PresentationLinkTransition.Options( - modalPresentationCapturesStatusBarAppearance: true, - preferredPresentationBackgroundColor: ThemeManager.currentBgColor - ))), + options: PresentationLinkTransition.SlideTransitionOptions( + edge: .trailing, + options: PresentationLinkTransition.Options( + modalPresentationCapturesStatusBarAppearance: true, + preferredPresentationBackgroundColor: ThemeManager.currentBgColor + ))), isPresented: $viewModel.presentWebContainer, destination: { WebReaderLoadingContainer(requestID: linkRequest.serverID) diff --git a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift index 79f9137b7..e98786b5a 100644 --- a/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/LibraryTabView.swift @@ -1,10 +1,3 @@ -// -// File.swift -// -// -// Created by Jackson Harper on 6/29/23. -// - import Foundation import Models import Services @@ -21,9 +14,6 @@ struct LibraryTabView: View { @AppStorage("LibraryTabView::hideFollowingTab") var hideFollowingTab = false @AppStorage(UserDefaultKey.lastSelectedTabItem.rawValue) var selectedTab = "inbox" - @AppStorage("LibraryTabView::digestEnabled") var digestEnabled = false - @AppStorage("LibraryTabView::hasCheckedForDigestFeature") var hasCheckedForDigestFeature = false - @State var isEditMode: EditMode = .inactive @State var showExpandedAudioPlayer = false @State var presentPushContainer = true @@ -79,6 +69,8 @@ struct LibraryTabView: View { @State var operationStatus: OperationStatus = .none @State var operationMessage: String? + @State var digestEnabled = false + var showDigest: Bool { if digestEnabled, #available(iOS 17.0, *) { return true @@ -143,7 +135,7 @@ struct LibraryTabView: View { if showDigest, #available(iOS 17.0, *) { NavigationView { - LibraryDigestView(dataService: dataService) + DigestView(dataService: dataService) .navigationBarTitleDisplayMode(.inline) .navigationViewStyle(.stack) }.tag("digest") @@ -165,8 +157,8 @@ struct LibraryTabView: View { } } - if let audioProperties = audioController.itemAudioProperties { - MiniPlayerViewer(itemAudioProperties: audioProperties) + if audioController.itemAudioProperties != nil { + MiniPlayerViewer() .onTapGesture { showExpandedAudioPlayer = true } @@ -236,19 +228,5 @@ struct LibraryTabView: View { } selectedTab = "inbox" } - .task { - do { - if let viewer = try await dataService.fetchViewer() { - digestEnabled = viewer.digestEnabled ?? false - if !hasCheckedForDigestFeature { - hasCheckedForDigestFeature = true - selectedTab = "digest" - } - } - } catch { - print("ERROR FETCHING VIEWER: ", error) - print("") - } - } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift index 634553453..34de3e874 100644 --- a/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift +++ b/apple/OmnivoreKit/Sources/App/Views/PrimaryContentView.swift @@ -52,7 +52,7 @@ import Transmission return AnyView(splitView) #endif } - + func startTimer(amount: Int) { self.snackbarTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(amount / 1000), repeats: false) { _ in DispatchQueue.main.async { diff --git a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift index 5eebee5ee..93491b275 100644 --- a/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift +++ b/apple/OmnivoreKit/Sources/App/Views/WebReader/WebReaderContainer.swift @@ -133,7 +133,7 @@ struct WebReaderContainerView: View { #if os(iOS) var audioNavbarItem: some View { - if !audioController.playbackError, audioController.isLoadingItem(itemID: item.unwrappedID) { + if audioController.isLoadingItem(audioController.itemAudioProperties) { return AnyView(ProgressView() .padding(.horizontal)) } else { @@ -500,20 +500,6 @@ struct WebReaderContainerView: View { .formSheet(isPresented: $showOpenArchiveSheet) { OpenArchiveTodayView(item: item) } - .formSheet(isPresented: $showExplainSheet) { - ExplainView( - viewModel: ExplainViewModel( - dataService: dataService, - item: self.item, - promptName: "explain-text-001", - extraText: viewModel.explainText - ), - dismissAction: { - viewModel.explainText = nil - showExplainSheet = false - } - ) - } #endif .sheet(isPresented: $showHighlightAnnotationModal) { NavigationView { @@ -629,8 +615,8 @@ struct WebReaderContainerView: View { .offset(y: navBarVisible ? 0 : -150) Spacer() - if let audioProperties = audioController.itemAudioProperties { - MiniPlayerViewer(itemAudioProperties: audioProperties) + if audioController.itemAudioProperties != nil { + MiniPlayerViewer() .padding(.top, 10) .padding(.bottom, showBottomBar ? 10 : 40) .background(Color.themeTabBarColor) diff --git a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift index 53aa7a504..cbf918195 100644 --- a/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift +++ b/apple/OmnivoreKit/Sources/Models/DataModels/FeedItem.swift @@ -27,8 +27,7 @@ public struct LinkedItemSyncResult { hasMore: Bool, mostRecentUpdatedAt: Date?, oldestUpdatedAt: Date?, - isEmpty: Bool) - { + isEmpty: Bool) { self.updatedItemIDs = updatedItemIDs self.cursor = cursor self.hasMore = hasMore @@ -38,7 +37,41 @@ public struct LinkedItemSyncResult { } } -public struct LinkedItemAudioProperties { +public enum AudioItemType { + case digest + case libraryItem +} + +public protocol AudioItemProperties { + var audioItemType: AudioItemType { + get + } + var itemID: String { + get + } + var title: String { + get + } + var byline: String? { + get + } + var imageURL: URL? { + get + } + var language: String? { + get + } + var startIndex: Int { + get + } + var startOffset: Double { + get + } +} + +public struct LinkedItemAudioProperties: AudioItemProperties { + public let audioItemType = AudioItemType.libraryItem + public let itemID: String public let objectID: NSManagedObjectID public let title: String diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift index 318c3388a..313265e66 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/AudioController.swift @@ -33,7 +33,7 @@ @Published public var currentAudioIndex: Int = 0 @Published public var readText: String = "" @Published public var unreadText: String = "" - @Published public var itemAudioProperties: LinkedItemAudioProperties? + @Published public var itemAudioProperties: AudioItemProperties? @Published public var timeElapsed: TimeInterval = 0 @Published public var duration: TimeInterval = 0 @@ -72,7 +72,7 @@ observer = nil } - public func play(itemAudioProperties: LinkedItemAudioProperties) { + public func play(itemAudioProperties: AudioItemProperties) { stop() playbackError = false @@ -539,11 +539,14 @@ state == .playing } - public func isLoadingItem(itemID: String) -> Bool { + public func isLoadingItem(_ audioItem: AudioItemProperties?) -> Bool { if state == .reachedEnd { return false } - return itemAudioProperties?.itemID == itemID && isLoading + if audioItem?.itemID == nil { + return false + } + return itemAudioProperties?.itemID == audioItem?.itemID && isLoading } public func isPlayingItem(itemID: String) -> Bool { @@ -860,8 +863,19 @@ } return "" } - + func downloadSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { + switch(self.itemAudioProperties?.audioItemType) { + case .digest: + return try await downloadDigestItemSpeechFile(itemID: itemID, priority: priority) + case .libraryItem: + return try await downloadLibraryItemSpeechFile(itemID: itemID, priority: priority) + case .none: + return nil + } + } + + func downloadLibraryItemSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { let decoder = JSONDecoder() let speechFileUrl = pathForSpeechFile(itemID: itemID) @@ -910,6 +924,60 @@ return nil } + func downloadDigestItemSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { + let decoder = JSONDecoder() + let speechFileUrl = URL.om_documentsDirectory.appendingPathComponent("digest").appendingPathComponent("speech-\(currentVoice).json") + + if FileManager.default.fileExists(atPath: speechFileUrl.path) { + let data = try Data(contentsOf: speechFileUrl) + document = try decoder.decode(SpeechDocument.self, from: data) + // If we can't load it from disk we make the API call + if let document = document { + return document + } + } + + let path = "/api/digest/v1/" + guard let url = URL(string: path, relativeTo: dataService.appEnvironment.serverBaseURL) else { + throw BasicError.message(messageText: "Invalid audio URL") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + for (header, value) in dataService.networker.defaultHeaders { + request.setValue(value, forHTTPHeaderField: header) + } + + let result: (Data, URLResponse)? = try? await URLSession.shared.data(for: request) + guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else { + throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.") + } + + guard let data = result?.0 else { + throw BasicError.message(messageText: "audioFetch failed. no data received.") + } + + let str = String(decoding: data, as: UTF8.self) + print("result digest file: ", str) + + do { + let digest = try JSONDecoder().decode(DigestResult.self, from: data) + let directory = URL.om_documentsDirectory.appendingPathComponent("digest") + // do { + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: speechFileUrl) + return digest.speechFile +// } catch { +// print("error writing file", error) +// } + // } + } catch { + print("error with digest file", error) + } + + return nil + } + func getSpeechFile(itemID: String, priority: DownloadPriority) async throws -> SpeechDocument? { document = try await downloadSpeechFile(itemID: itemID, priority: priority) return document diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift index 2939c2cf4..2946c0fb0 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/SpeechSynthesizer.swift @@ -20,7 +20,7 @@ struct UtteranceRequest: Codable { let isOpenAIVoice: Bool } -struct Utterance: Decodable { +public struct Utterance: Decodable { public let idx: String public let text: String public let voice: String? @@ -39,7 +39,7 @@ struct Utterance: Decodable { } } -struct SpeechDocument: Decodable { +public struct SpeechDocument: Decodable { static let averageWPM: Double = 195 public let pageId: String diff --git a/apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift b/apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift new file mode 100644 index 000000000..7ea58022c --- /dev/null +++ b/apple/OmnivoreKit/Sources/Services/DataService/AI/AITasks.swift @@ -0,0 +1,118 @@ +import CoreData +import Foundation +import Models +import Utils + +struct AITaskRequest: Decodable { + public let requestId: String +} + +public struct DigestResult: Decodable { + public let id: String + public let title: String + public let content: String + public let urlsToAudio: [String] + public let speechFile: SpeechDocument + + public let jobState: String +} + +public struct DigestItem: Decodable { + public let id: String + public let site: String + public let siteIcon: URL? + public let author: String + public let title: String + public let summaryText: String + public let keyPointsText: String + public let highlightsText: String + public init(id: String, site: String, siteIcon: URL?, + author: String, title: String, summaryText: String, + keyPointsText: String, highlightsText: String) { + self.id = id + self.site = site + self.siteIcon = siteIcon + self.author = author + self.title = title + self.summaryText = summaryText + self.keyPointsText = keyPointsText + self.highlightsText = highlightsText + } +} + +extension DataService { +// public func createAITask(extraText: String?, libraryItemId: String, promptName: String) async throws -> String? { +// let jsonData = try JSONSerialization.data(withJSONObject: [ +// "libraryItemId": libraryItemId, +// "promptName": promptName, +// "extraText": extraText +// ]) +// +// let urlRequest = URLRequest.create( +// baseURL: appEnvironment.serverBaseURL, +// urlPath: "/api/ai-task", +// requestMethod: .post(params: jsonData), +// includeAuthToken: true +// ) +// let resource = ServerResource( +// urlRequest: urlRequest, +// decode: AITaskRequest.decode +// ) +// +// do { +// let taskRequest = try await networker.urlSession.performRequest(resource: resource) +// return taskRequest.requestId +// } catch { +// return nil +// } +// } + + // Function to poll the status of the AI task with timeout + public func getLatestDigest(timeoutInterval: TimeInterval) async throws -> DigestResult? { + var count = 0 + let startTime = Date() + while true { + count += 1 + if count > 3 { + return nil + } + do { + // Check if timeout has occurred + if -startTime.timeIntervalSinceNow >= timeoutInterval { + throw NSError(domain: "Timeout Error", code: -1, userInfo: nil) + } + + let urlRequest = URLRequest.create( + baseURL: appEnvironment.serverBaseURL, + urlPath: "/api/digest/v1/", + requestMethod: .get, + includeAuthToken: true + ) + + let resource = ServerResource( + urlRequest: urlRequest, + decode: DigestResult.decode + ) + + do { + let digest = try await networker.urlSession.performRequest(resource: resource) + print("GOT RESPONSE: ", digest) + return digest + } catch { + print("ERROR FETCHING TASK: ", error) +// if let response = error as? ServerError { +// if response != .stillProcessing { +// return nil +// } +// } + } + // Wait for some time before polling again + try? await Task.sleep(nanoseconds: 3_000_000_000) + } catch let error { + throw error + } + } + } +} + +