Merge branch 'main' of github.com:omnivore-app/omnivore into feat/dragndrop

This commit is contained in:
Rupin Khandelwal 2022-11-21 12:33:26 -03:00
commit f3aa89b96c
201 changed files with 7035 additions and 1945 deletions

View file

@ -90,5 +90,5 @@ jobs:
run: 'docker build --file packages/content-fetch/Dockerfile .'
- name: Build the inbound-email-handler docker image
run: 'docker build --file packages/inbound-email-handler/Dockerfile .'
- name: Build the puppeteer-parse docker image
run: 'docker build --file packages/puppeteer-parse/Dockerfile .'
- name: Build the content-fetch cloud function docker image
run: 'docker build --file packages/content-fetch/Dockerfile-gcf .'

View file

@ -0,0 +1,23 @@
mutation MergeHighlight($input: MergeHighlightInput!) {
mergeHighlight(input: $input) {
... on MergeHighlightSuccess {
highlight {
id
shortId
quote
prefix
suffix
patch
createdAt
updatedAt
annotation
sharedAt
createdByMe
}
overlapHighlightIdList
}
... on MergeHighlightError {
errorCodes
}
}
}

View file

@ -2,9 +2,14 @@ package app.omnivore.omnivore.networking
import android.util.Log
import app.omnivore.omnivore.graphql.generated.CreateHighlightMutation
import app.omnivore.omnivore.graphql.generated.DeleteHighlightMutation
import app.omnivore.omnivore.graphql.generated.MergeHighlightMutation
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput
import app.omnivore.omnivore.models.Highlight
import com.apollographql.apollo3.api.Optional
import com.google.gson.Gson
import com.pspdfkit.annotations.HighlightAnnotation
data class CreateHighlightParams(
val shortId: String?,
@ -24,13 +29,49 @@ data class CreateHighlightParams(
)
}
suspend fun Networker.createHighlight(jsonString: String): Boolean {
val input = Gson().fromJson(jsonString, CreateHighlightParams::class.java).asCreateHighlightInput()
suspend fun Networker.deleteHighlights(highlightIDs: List<String>): Boolean {
val statuses: MutableList<Boolean> = mutableListOf()
for (highlightID in highlightIDs) {
val result = authenticatedApolloClient().mutation(DeleteHighlightMutation(highlightID)).execute()
statuses.add(result.data?.deleteHighlight?.onDeleteHighlightSuccess?.highlight != null)
}
val hasFailure = statuses.any { !it }
return !hasFailure
}
suspend fun Networker.mergeHighlights(input: MergeHighlightInput): Boolean {
val result = authenticatedApolloClient().mutation(MergeHighlightMutation(input)).execute()
Log.d("Network", "highlight merge result: $result")
return result.data?.mergeHighlight?.onMergeHighlightSuccess?.highlight != null
}
suspend fun Networker.createWebHighlight(jsonString: String): Boolean {
val input = Gson().fromJson(jsonString, CreateHighlightParams::class.java).asCreateHighlightInput()
return createHighlight(input) != null
}
suspend fun Networker.createHighlight(input: CreateHighlightInput): Highlight? {
Log.d("Loggo", "created highlight input: $input")
val result = authenticatedApolloClient().mutation(CreateHighlightMutation(input)).execute()
val highlight = result.data?.createHighlight?.onCreateHighlightSuccess?.highlight
return highlight != null
val createdHighlight = result.data?.createHighlight?.onCreateHighlightSuccess?.highlight
if (createdHighlight != null) {
return Highlight(
id = createdHighlight.highlightFields.id,
shortId = createdHighlight.highlightFields.shortId,
quote = createdHighlight.highlightFields.quote,
prefix = createdHighlight.highlightFields.prefix,
suffix = createdHighlight.highlightFields.suffix,
patch = createdHighlight.highlightFields.patch,
annotation = createdHighlight.highlightFields.annotation,
createdAt = null, // TODO: update gql query to get this
updatedAt = createdHighlight.highlightFields.updatedAt,
createdByMe = createdHighlight.highlightFields.createdByMe,
)
} else {
return null
}
}

View file

@ -19,8 +19,13 @@ data class ReadingProgressParams(
)
}
suspend fun Networker.updateReadingProgress(jsonString: String): Boolean {
val input = Gson().fromJson(jsonString, ReadingProgressParams::class.java).asSaveReadingProgressInput()
suspend fun Networker.updateWebReadingProgress(jsonString: String): Boolean {
val params = Gson().fromJson(jsonString, ReadingProgressParams::class.java)
return updateReadingProgress(params)
}
suspend fun Networker.updateReadingProgress(params: ReadingProgressParams): Boolean {
val input = params.asSaveReadingProgressInput()
Log.d("Loggo", "created reading progress input: $input")

View file

@ -1,5 +1,9 @@
package app.omnivore.omnivore.ui.reader
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@ -10,7 +14,34 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.fragment.app.Fragment
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
class AnnotationEditFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return ComposeView(requireContext()).apply {
// Dispose of the Composition when the view's LifecycleOwner
// is destroyed
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
OmnivoreTheme {
AnnotationEditView(
initialAnnotation = "Initial Annotation",
onSave = {},
onCancel = {}
)
}
}
}
}
}
// TODO: better layout and styling for this view
@OptIn(ExperimentalMaterial3Api::class)

View file

@ -1,31 +1,73 @@
package app.omnivore.omnivore.ui.reader
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.PointF
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.ImageView
import android.widget.PopupMenu
import androidx.activity.viewModels
import androidx.annotation.UiThread
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.lifecycle.Observer
import app.omnivore.omnivore.R
import com.google.gson.Gson
import app.omnivore.omnivore.models.Highlight
import com.pspdfkit.annotations.Annotation
import com.pspdfkit.annotations.HighlightAnnotation
import com.pspdfkit.configuration.PdfConfiguration
import com.pspdfkit.configuration.activity.ThumbnailBarMode
import com.pspdfkit.configuration.page.PageScrollDirection
import com.pspdfkit.datastructures.TextSelection
import com.pspdfkit.document.PdfDocument
import com.pspdfkit.document.search.SearchResult
import com.pspdfkit.listeners.DocumentListener
import com.pspdfkit.listeners.OnPreparePopupToolbarListener
import com.pspdfkit.ui.PdfFragment
import com.pspdfkit.ui.PdfThumbnailBar
import com.pspdfkit.ui.PopupToolbar
import com.pspdfkit.ui.search.PdfSearchViewModular
import com.pspdfkit.ui.search.SimpleSearchResultListener
import com.pspdfkit.ui.special_mode.controller.TextSelectionController
import com.pspdfkit.ui.special_mode.manager.TextSelectionManager
import com.pspdfkit.ui.toolbar.popup.PdfTextSelectionPopupToolbar
import com.pspdfkit.ui.toolbar.popup.PopupToolbarMenuItem
import com.pspdfkit.utils.PdfUtils
import dagger.hilt.android.AndroidEntryPoint
import org.json.JSONObject
@AndroidEntryPoint
class PDFReaderActivity: AppCompatActivity(), DocumentListener {
class PDFReaderActivity: AppCompatActivity(), DocumentListener, TextSelectionManager.OnTextSelectionChangeListener, TextSelectionManager.OnTextSelectionModeChangeListener, OnPreparePopupToolbarListener {
private var hasLoadedHighlights = false
private var pendingHighlightAnnotation: HighlightAnnotation? = null
private var textSelectionController: TextSelectionController? = null
private lateinit var fragment: PdfFragment
private lateinit var thumbnailBar: PdfThumbnailBar
private lateinit var configuration: PdfConfiguration
private lateinit var modularSearchView: PdfSearchViewModular
val viewModel: PDFReaderViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
configuration = PdfConfiguration.Builder()
.scrollDirection(PageScrollDirection.HORIZONTAL)
.build()
setContentView(R.layout.pdf_reader_fragment)
// Create the observer which updates the UI.
@ -42,18 +84,26 @@ class PDFReaderActivity: AppCompatActivity(), DocumentListener {
viewModel.loadItem(slug, this)
}
private fun load(params: PDFReaderParams) {
val configuration = PdfConfiguration.Builder()
.scrollDirection(PageScrollDirection.HORIZONTAL)
.build()
// TODO: implement onDestroy to remove listeners?
private fun load(params: PDFReaderParams) {
// First, try to restore a previously created fragment.
// If no fragment exists, create a new one.
fragment = supportFragmentManager.findFragmentById(R.id.fragmentContainer) as PdfFragment?
?: createFragment(params.localFileUri, configuration)
// Initialize all PSPDFKit UI components.
initModularSearchViewAndButton()
initThumbnailBar()
fragment.apply {
setOnPreparePopupToolbarListener(this@PDFReaderActivity)
addOnTextSelectionModeChangeListener(this@PDFReaderActivity)
addOnTextSelectionChangeListener(this@PDFReaderActivity)
addDocumentListener(this@PDFReaderActivity)
addDocumentListener(modularSearchView)
addDocumentListener(thumbnailBar.documentListener)
isImmersive = true
}
}
@ -61,19 +111,14 @@ class PDFReaderActivity: AppCompatActivity(), DocumentListener {
if (hasLoadedHighlights) return
hasLoadedHighlights = true
thumbnailBar.setDocument(document, configuration)
fragment.addDocumentListener(modularSearchView)
modularSearchView.setDocument(document, configuration)
val params = viewModel.pdfReaderParamsLiveData.value
params?.let {
for (highlight in it.articleContent.highlights) {
val highlightAnnotation = fragment
.document
?.annotationProvider
?.createAnnotationFromInstantJson(highlight.patch)
highlightAnnotation?.let {
fragment.addAnnotationToPage(highlightAnnotation, true)
}
}
loadHighlights(it.articleContent.highlights)
fragment.scrollTo(
RectF(0f, 0f, 0f, 0f),
@ -84,6 +129,19 @@ class PDFReaderActivity: AppCompatActivity(), DocumentListener {
}
}
private fun loadHighlights(highlights: List<Highlight>) {
for (highlight in highlights) {
val highlightAnnotation = fragment
.document
?.annotationProvider
?.createAnnotationFromInstantJson(highlight.patch)
highlightAnnotation?.let {
fragment.addAnnotationToPage(highlightAnnotation, true)
}
}
}
private fun createFragment(documentUri: Uri, configuration: PdfConfiguration): PdfFragment {
val fragment = PdfFragment.newInstance(documentUri, configuration)
supportFragmentManager.beginTransaction()
@ -91,4 +149,256 @@ class PDFReaderActivity: AppCompatActivity(), DocumentListener {
.commit()
return fragment
}
private fun initThumbnailBar() {
thumbnailBar = findViewById(R.id.thumbnailBar)
?: throw IllegalStateException("Error while loading CustomFragmentActivity. The example layout was missing thumbnail bar view.")
thumbnailBar.setOnPageChangedListener { _, pageIndex: Int -> fragment.pageIndex = pageIndex }
thumbnailBar.setThumbnailBarMode(ThumbnailBarMode.THUMBNAIL_BAR_MODE_FLOATING)
val toggleThumbnailButton = findViewById<ImageView>(R.id.toggleThumbnailButton)
?: throw IllegalStateException(
"Error while loading CustomFragmentActivity. The example layout " +
"was missing the open search button with id `R.id.openThumbnailGridButton`."
)
toggleThumbnailButton.apply {
setImageDrawable(
tintDrawable(
drawable,
ContextCompat.getColor(this@PDFReaderActivity, R.color.black)
)
)
setOnClickListener {
if (thumbnailBar.visibility == View.VISIBLE) {
thumbnailBar.visibility = View.INVISIBLE
} else {
thumbnailBar.visibility = View.VISIBLE
}
}
}
}
private fun initModularSearchViewAndButton() {
// The search view is hidden by default (see layout). Set up a click listener that will show the view once pressed.
val openSearchButton = findViewById<ImageView>(R.id.openSearchButton)
?: throw IllegalStateException(
"Error while loading CustomFragmentActivity. The example layout " +
"was missing the open search button with id `R.id.openSearchButton`."
)
val closeSearchButton = findViewById<ImageView>(R.id.closeSearchButton)
?: throw IllegalStateException(
"Error while loading CustomFragmentActivity. The example layout " +
"was missing the close search button with id `R.id.closeSearchButton`."
)
modularSearchView = findViewById(R.id.modularSearchView)
?: throw IllegalStateException("Error while loading CustomFragmentActivity. The example layout was missing the search view.")
modularSearchView.setSearchViewListener(object : SimpleSearchResultListener() {
override fun onSearchResultSelected(result: SearchResult?) {
// Pass on the search result to the highlighter. If 'null' the highlighter will clear any selection.
if (result != null) {
closeSearchButton.visibility = View.INVISIBLE
fragment.scrollTo(PdfUtils.createPdfRectUnion(result.textBlock.pageRects), result.pageIndex, 250, false)
}
}
})
openSearchButton.apply {
setImageDrawable(
tintDrawable(
drawable,
ContextCompat.getColor(this@PDFReaderActivity, R.color.black)
)
)
setOnClickListener {
closeSearchButton.visibility = View.VISIBLE
modularSearchView.show()
}
}
closeSearchButton.apply {
setImageDrawable(
tintDrawable(
drawable,
ContextCompat.getColor(this@PDFReaderActivity, R.color.white)
)
)
setOnClickListener {
closeSearchButton.visibility = View.INVISIBLE
modularSearchView.hide()
}
}
}
override fun onBackPressed() {
when {
modularSearchView.isDisplayed -> {
modularSearchView.hide()
return
}
else -> super.onBackPressed()
}
}
override fun onPageClick(
document: PdfDocument,
pageIndex: Int,
event: MotionEvent?,
pagePosition: PointF?,
clickedAnnotation: Annotation?
): Boolean {
if (clickedAnnotation != null) {
showHighlightSelectionPopover(clickedAnnotation)
}
return super.onPageClick(document, pageIndex, event, pagePosition, clickedAnnotation)
}
override fun onPageChanged(document: PdfDocument, pageIndex: Int) {
viewModel.syncPageChange(pageIndex, document.pageCount)
super.onPageChanged(document, pageIndex)
}
private fun showHighlightSelectionPopover(clickedAnnotation: Annotation) {
// TODO: anchor popover at exact position of tap (maybe add an empty view at tap loc and anchor to that?)
val popupMenu = PopupMenu(this, fragment.view, Gravity.CENTER, androidx.appcompat.R.attr.actionOverflowMenuStyle, 0)
popupMenu.menuInflater.inflate(R.menu.highlight_selection_menu, popupMenu.menu)
popupMenu.setOnMenuItemClickListener { item ->
when(item.itemId) {
R.id.annotate -> {
viewModel.annotationUnderNoteEdit = clickedAnnotation
// Disabled notes for now since we didn't implement on ios
// showAnnotationView()
}
R.id.delete -> {
viewModel.deleteHighlight(clickedAnnotation)
fragment.document?.annotationProvider?.removeAnnotationFromPage(clickedAnnotation)
}
R.id.copyPdfHighlight -> {
val omnivoreHighlight = clickedAnnotation.customData?.get("omnivoreHighlight") as? JSONObject
val quote = omnivoreHighlight?.get("quote") as? String
quote?.let {
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(it, it)
clipboard.setPrimaryClip(clip)
}
}
}
true
}
popupMenu.show()
}
private fun tintDrawable(drawable: Drawable, tint: Int): Drawable {
val tintedDrawable = DrawableCompat.wrap(drawable)
DrawableCompat.setTint(tintedDrawable, tint)
return tintedDrawable
}
override fun onBeforeTextSelectionChange(p0: TextSelection?, p1: TextSelection?): Boolean {
return true
}
override fun onAfterTextSelectionChange(p0: TextSelection?, p1: TextSelection?) {
val textRects = p0?.textBlocks ?: return
val pageIndex = p0.pageIndex
pendingHighlightAnnotation = HighlightAnnotation(pageIndex, textRects)
}
override fun onEnterTextSelectionMode(p0: TextSelectionController) {
val textRects = p0?.textSelection?.textBlocks ?: return
val pageIndex = p0.textSelection?.pageIndex ?: return
pendingHighlightAnnotation = HighlightAnnotation(pageIndex, textRects)
textSelectionController = p0
}
override fun onExitTextSelectionMode(p0: TextSelectionController) {
textSelectionController = null
pendingHighlightAnnotation = null
}
@SuppressLint("ResourceType")
override fun onPrepareTextSelectionPopupToolbar(p0: PdfTextSelectionPopupToolbar) {
val onClickListener = PopupToolbar.OnPopupToolbarItemClickedListener { it ->
when (it.id) {
1 -> {
pendingHighlightAnnotation?.let { annotation ->
val quote = textSelectionController?.textSelection?.text ?: ""
val existingAnnotations = fragment.document?.annotationProvider?.getAnnotations(fragment.pageIndex) ?: listOf()
val overlappingAnnotations = viewModel.overlappingAnnotations(annotation, existingAnnotations)
val overlapIDs = overlappingAnnotations.mapNotNull { viewModel.pluckHighlightID(it) }
for (overlappingAnnotation in overlappingAnnotations) {
fragment.document?.annotationProvider?.removeAnnotationFromPage(overlappingAnnotation)
}
fragment.addAnnotationToPage(annotation, false) {
viewModel.syncHighlightUpdates(annotation, quote, overlapIDs)
}
}
textSelectionController?.textSelection = null
p0.dismiss()
return@OnPopupToolbarItemClickedListener true
}
// 2 -> {
// Log.d("pdf", "user selected annotate action")
// textSelectionController?.textSelection = null
// p0.dismiss()
// return@OnPopupToolbarItemClickedListener true
// }
3 -> {
val text = textSelectionController?.textSelection?.text ?: ""
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(text, text)
clipboard.setPrimaryClip(clip)
textSelectionController?.textSelection = null
p0.dismiss()
return@OnPopupToolbarItemClickedListener true
}
else -> {
p0.dismiss()
textSelectionController?.textSelection = null
return@OnPopupToolbarItemClickedListener false
}
}
}
p0.setOnPopupToolbarItemClickedListener(onClickListener)
p0.menuItems = listOf(
PopupToolbarMenuItem(1, R.string.pdf_highlight_menu_action),
// PopupToolbarMenuItem(2, R.string.annotate_menu_action),
PopupToolbarMenuItem(3, R.string.pdf_highlight_copy),
)
}
private fun showAnnotationView(initialText: String) {
val annotationDialog = Dialog(this)
annotationDialog.setContentView(R.layout.annotation_edit)
val textField = annotationDialog.findViewById(R.id.highlightNoteTextField) as EditText
textField.setText(initialText)
val confirmButton = annotationDialog.findViewById(R.id.confirmAnnotation) as Button
confirmButton.setOnClickListener {
val newNoteText =
annotationDialog.dismiss()
}
val cancelBtn = annotationDialog.findViewById(R.id.cancel) as Button
cancelBtn.setOnClickListener {
annotationDialog.dismiss()
}
annotationDialog.show()
}
}

View file

@ -7,17 +7,24 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput
import app.omnivore.omnivore.models.LinkedItem
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.networking.linkedItem
import app.omnivore.omnivore.networking.*
import com.apollographql.apollo3.api.Optional
import com.google.gson.Gson
import com.pspdfkit.annotations.Annotation
import com.pspdfkit.annotations.HighlightAnnotation
import com.pspdfkit.document.download.DownloadJob
import com.pspdfkit.document.download.DownloadRequest
import com.pspdfkit.document.download.Progress
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.io.File
import java.lang.Double.max
import java.lang.Double.min
import java.util.*
import javax.inject.Inject
data class PDFReaderParams(
@ -31,8 +38,9 @@ class PDFReaderViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository,
private val networker: Networker
): ViewModel() {
var annotationUnderNoteEdit: Annotation? = null
val pdfReaderParamsLiveData = MutableLiveData<PDFReaderParams?>(null)
var annotations: List<Annotation> = listOf()
private var currentReadingProgress = 0.0
fun loadItem(slug: String, context: Context) {
viewModelScope.launch {
@ -61,6 +69,7 @@ class PDFReaderViewModel @Inject constructor(
labelsJSONString = Gson().toJson(articleQueryResult.labels)
)
currentReadingProgress = article.readingProgress
pdfReaderParamsLiveData.postValue(PDFReaderParams(article, articleContent, Uri.fromFile(output)))
}
@ -74,4 +83,100 @@ class PDFReaderViewModel @Inject constructor(
fun reset() {
pdfReaderParamsLiveData.postValue(null)
}
fun syncPageChange(currentPageIndex: Int, totalPages: Int) {
val rawProgress = ((currentPageIndex + 1).toDouble() / totalPages.toDouble()) * 100
val percent = min(100.0, max(0.0, rawProgress))
if (percent > currentReadingProgress) {
currentReadingProgress = percent
viewModelScope.launch {
val params = ReadingProgressParams(
id = pdfReaderParamsLiveData.value?.item?.id,
readingProgressPercent = percent,
readingProgressAnchorIndex = currentPageIndex
)
networker.updateReadingProgress(params)
}
}
}
fun syncHighlightUpdates(newAnnotation: Annotation, quote: String, overlapIds: List<String>) {
val itemID = pdfReaderParamsLiveData.value?.item?.id ?: return
val highlightID = UUID.randomUUID().toString()
val shortID = UUID.randomUUID().toString().replace("-","").substring(0,8)
val jsonValues = JSONObject()
.put("id", highlightID)
.put("shortId", shortID)
.put("quote", quote)
.put("articleId", itemID)
newAnnotation.customData = JSONObject().put("omnivoreHighlight", jsonValues)
if (overlapIds.isNotEmpty()) {
val input = MergeHighlightInput(
annotation = Optional.presentIfNotNull(newAnnotation.contents),
articleId = itemID,
id = highlightID,
overlapHighlightIdList = overlapIds,
patch = newAnnotation.toInstantJson(),
quote = quote,
shortId = shortID
)
viewModelScope.launch {
networker.mergeHighlights(input)
}
} else {
val createHighlightInput = CreateHighlightInput(
annotation = Optional.presentIfNotNull(null),
articleId = itemID,
id = highlightID,
patch = newAnnotation.toInstantJson(),
quote = quote,
shortId = shortID,
)
viewModelScope.launch {
networker.createHighlight(createHighlightInput)
}
}
}
fun deleteHighlight(annotation: Annotation) {
val highlightID = pluckHighlightID(annotation) ?: return
viewModelScope.launch {
networker.deleteHighlights(listOf(highlightID))
Log.d("network", "deleted $annotation")
}
}
fun overlappingAnnotations(newAnnotation: Annotation, existingAnnotations: List<Annotation>): List<Annotation> {
val result: MutableList<Annotation> = mutableListOf()
for (existingAnnotation in existingAnnotations) {
if (hasOverlaps(newAnnotation, existingAnnotation)) {
result.add(existingAnnotation)
}
}
return result
}
fun pluckHighlightID(annotation: Annotation): String? {
val omnivoreHighlight = annotation.customData?.get("omnivoreHighlight") as? JSONObject
return omnivoreHighlight?.get("id") as? String
}
private fun hasOverlaps(leftAnnotation: Annotation, rightAnnotation: Annotation): Boolean {
for (leftRect in (leftAnnotation as? HighlightAnnotation)?.rects ?: listOf()) {
for (rightRect in (rightAnnotation as? HighlightAnnotation)?.rects ?: listOf()) {
if (rightRect.intersect(leftRect)) {
return true
}
}
}
return false
}
}

View file

@ -62,7 +62,7 @@ class WebReaderViewModel @Inject constructor(
when (actionID) {
"createHighlight" -> {
viewModelScope.launch {
val isHighlightSynced = networker.createHighlight(jsonString)
val isHighlightSynced = networker.createWebHighlight(jsonString)
Log.d("Network", "isHighlightSynced = $isHighlightSynced")
}
}
@ -75,7 +75,7 @@ class WebReaderViewModel @Inject constructor(
}
"articleReadingProgress" -> {
viewModelScope.launch {
val isReadingProgressSynced = networker.updateReadingProgress(jsonString)
val isReadingProgressSynced = networker.updateWebReadingProgress(jsonString)
Log.d("Network", "isReadingProgressSynced = $isReadingProgressSynced")
}
}

View file

@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000" android:pathData="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/></vector>

View file

@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000" android:pathData="M21 4H3C2.45 4 2 4.45 2 5V19C2 19.55 2.45 20 3 20H21C21.55 20 22 19.55 22 19V5C22 4.45 21.55 4 21 4M8 18H4V6H8V18M14 18H10V6H14V18M20 18H16V6H20V18Z"/></vector>

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="fill_parent" android:layout_height="fill_parent"
android:background="#ffffff">
<EditText
android:layout_width="fill_parent"
android:layout_gravity="center"
android:layout_height="wrap_content"
android:hint="Add note."
android:id="@+id/highlightNoteTextField"
android:textColor="#000000"
android:lines="3" />
<LinearLayout android:layout_width="fill_parent"
android:layout_height="fill_parent">
<Button android:id="@+id/confirmAnnotation" android:layout_height="wrap_content"
android:layout_width="wrap_content" android:text="Confirm" android:layout_weight="1" />
<Button android:id="@+id/cancel" android:layout_height="wrap_content"
android:layout_width="wrap_content" android:layout_weight="1"
android:text="Cancel" />
</LinearLayout>
</LinearLayout>

View file

@ -22,15 +22,43 @@
android:elevation="8dp"
android:visibility="visible"/>
<com.pspdfkit.ui.PdfThumbnailGrid
android:id="@+id/thumbnailGrid"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="invisible"/>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:orientation="horizontal"
android:elevation="16dp"
android:splitMotionEvents="false">
<com.pspdfkit.ui.PdfOutlineView
android:id="@+id/outlineView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="invisible"/>
<ImageView
android:id="@+id/closeSearchButton"
android:layout_width="50dp"
android:layout_height="50dp"
android:padding="12dp"
android:elevation="16dp"
android:visibility="invisible"
android:src="@drawable/close" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:orientation="horizontal"
android:splitMotionEvents="false">
<ImageView
android:id="@+id/openSearchButton"
android:layout_width="50dp"
android:layout_height="50dp"
android:padding="12dp"
android:src="@drawable/pspdf__ic_search" />
<ImageView
android:id="@+id/toggleThumbnailButton"
android:layout_width="50dp"
android:layout_height="50dp"
android:padding="12dp"
android:src="@drawable/pdf_thumbnail_toggle" />
</LinearLayout>
</FrameLayout>

View file

@ -3,13 +3,13 @@
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/delete"
android:title="@string/delete_highlight_menu_title"
android:title="@string/pdf_remove_highlight"
app:showAsAction="always">
</item>
<item
android:id="@+id/annotate"
android:title="@string/annotate_menu_action"
android:id="@+id/copyPdfHighlight"
android:title="@string/pdf_highlight_copy"
app:showAsAction="always">
</item>
</menu>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/createHighlight"
android:title="@string/pdf_highlight_menu_action"
app:showAsAction="always">
</item>
<item
android:id="@+id/copyPdfHighlight"
android:title="@string/pdf_highlight_copy"
app:showAsAction="always">
</item>
</menu>

View file

@ -4,6 +4,9 @@
<string name="learn_more">Learn More</string>
<string name="welcome_subtitle">Save articles and read them later in our distraction-free reader.</string>
<string name="highlight_menu_action">Highlight</string>
<string name="copy_menu_action">Copy</string>
<string name="annotate_menu_action">Annotate</string>
<string name="delete_highlight_menu_title">Delete</string>
<string name="pdf_remove_highlight">Remove</string>
<string name="pdf_highlight_menu_action">Highlight</string>
<string name="pdf_highlight_copy">Copy</string>
</resources>

View file

@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.omnivore.fetchLinkedItems</string>

View file

@ -1521,7 +1521,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.20.0;
MARKETING_VERSION = 1.21.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1600,7 +1600,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.20.0;
MARKETING_VERSION = 1.21.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@ -1639,7 +1639,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.20.0;
MARKETING_VERSION = 1.21.0;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"-framework",
@ -1804,7 +1804,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.20.0;
MARKETING_VERSION = 1.21.0;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
PRODUCT_NAME = ShareExtension;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1859,7 +1859,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.20.0;
MARKETING_VERSION = 1.21.0;
PRODUCT_BUNDLE_IDENTIFIER = app.omnivore.app;
PRODUCT_NAME = Omnivore;
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1888,7 +1888,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.20.0;
MARKETING_VERSION = 1.21.0;
PRODUCT_BUNDLE_IDENTIFIER = "app.omnivore.app.share-extension";
PRODUCT_NAME = ShareExtension;
PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -1,14 +1,15 @@
import CoreData
import Models
import Services
import SwiftUI
import Utils
import Views
public class ShareExtensionViewModel: ObservableObject {
@Published public var status: ShareExtensionStatus = .processing
@Published public var title: String?
@Published public var title: String = ""
@Published public var url: String?
@Published public var iconURL: String?
@Published public var highlightData: HighlightData?
@Published public var linkedItem: LinkedItem?
@Published public var requestId = UUID().uuidString.lowercased()
@Published var debugText: String?
@ -42,6 +43,22 @@ public class ShareExtensionViewModel: ObservableObject {
}
}
func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) {
dataService.archiveLink(objectID: objectID, archived: archived)
}
func removeLink(dataService: DataService, objectID: NSManagedObjectID) {
dataService.removeLink(objectID: objectID)
}
func submitTitleEdit(dataService: DataService, itemID: String, title: String, description: String) {
dataService.updateLinkedItemTitleAndDescription(
itemID: itemID,
title: title,
description: description
)
}
#if os(iOS)
func queueSaveOperation(_ payload: PageScrapePayload) {
ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in
@ -67,30 +84,19 @@ public class ShareExtensionViewModel: ObservableObject {
DispatchQueue.main.async {
self.status = .saved
let url = URLComponents(string: payload.url)
let hostname = URL(string: payload.url)?.host ?? ""
switch payload.contentType {
case let .html(html: _, title: title, iconURL: iconURL):
self.title = title
self.iconURL = iconURL
case let .html(html: _, title: title, highlightData: highlightData):
self.title = title ?? ""
self.url = hostname
self.highlightData = highlightData
case .none:
self.url = hostname
self.title = payload.url
if var url = url {
url.path = "/favicon.ico"
self.iconURL = url.url?.absoluteString
}
case let .pdf(localUrl: localUrl):
self.url = hostname
self.title = PDFUtils.titleFromPdfFile(localUrl.absoluteString)
Task {
let localThumbnail = try await PDFUtils.createThumbnailFor(inputUrl: localUrl)
DispatchQueue.main.async {
self.iconURL = localThumbnail?.absoluteString
}
}
}
}
@ -155,6 +161,15 @@ public class ShareExtensionViewModel: ObservableObject {
}
updateStatusOnMain(requestId: newRequestID, newStatus: .synced)
// Prefetch the newly saved content
if let itemID = newRequestID,
let currentViewer = services.dataService.currentViewer?.username,
(try? await services.dataService.loadArticleContentWithRetries(itemID: itemID, username: currentViewer)) != nil
{
updateStatusOnMain(requestId: requestId, newStatus: .synced, objectID: linkedItemObjectID)
}
return true
}
@ -167,12 +182,20 @@ public class ShareExtensionViewModel: ObservableObject {
if let objectID = objectID {
self.linkedItem = self.services.dataService.viewContext.object(with: objectID) as? LinkedItem
if let title = self.linkedItem?.title {
self.title = title
}
self.url = self.linkedItem?.pageURLString
}
}
}
}
public enum ShareExtensionStatus {
public enum ShareExtensionStatus: Equatable {
public static func == (lhs: ShareExtensionStatus, rhs: ShareExtensionStatus) -> Bool {
lhs.displayMessage == rhs.displayMessage
}
case processing
case saved
case synced

View file

@ -6,10 +6,29 @@ import Views
public struct ShareExtensionView: View {
let extensionContext: NSExtensionContext?
@EnvironmentObject var dataService: DataService
@StateObject var labelsViewModel = LabelsViewModel()
@StateObject private var viewModel = ShareExtensionViewModel()
@State var reminderTime: ReminderTime?
@State var hideUntilReminded = false
@State var previousLabels: [LinkedItemLabel]?
@State var messageText: String?
@State var viewState = ViewState.mainView
enum FocusField: Hashable {
case titleEditor
}
enum ViewState {
case mainView
case editingTitle
case editingLabels
case viewingHighlight
}
@FocusState private var focusedField: FocusField?
private func handleReminderTimeSelection(_ selectedTime: ReminderTime) {
if selectedTime == reminderTime {
@ -32,27 +51,14 @@ public struct ShareExtensionView: View {
}
}
private var cloudIconName: String {
private var titleColor: Color {
switch viewModel.status {
case .synced:
return "checkmark.icloud"
case .saved, .processing:
return "icloud"
case .failed(error: _), .syncFailed(error: _):
return "exclamationmark.icloud"
}
}
private var cloudIconColor: Color {
switch viewModel.status {
case .saved:
return .appGrayText
case .processing:
return .clear
case .failed(error: _), .syncFailed(error: _):
return .red
case .synced:
return .blue
return .appGreenSuccess
}
}
@ -69,126 +75,416 @@ public struct ShareExtensionView: View {
return nil
}
public var previewCard: some View {
var isSynced: Bool {
switch viewModel.status {
case .synced:
return true
default:
return false
}
}
var titleBar: some View {
HStack {
if let iconURLStr = viewModel.iconURL, let iconURL = URL(string: iconURLStr) {
if !iconURL.isFileURL {
AsyncImage(
url: iconURL,
content: { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 61, height: 61)
.clipped()
},
placeholder: {
Color.appButtonBackground
.aspectRatio(contentMode: .fill)
.frame(width: 61, height: 61)
}
)
} else {
if let localImage = localImage(from: iconURL) {
localImage
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 61, height: 61)
.clipped()
} else {
Color.appButtonBackground
.aspectRatio(contentMode: .fill)
.frame(width: 61, height: 61)
Spacer()
Image(systemName: "checkmark.circle")
.frame(width: 15, height: 15)
.foregroundColor(.appGreenSuccess)
.opacity(isSynced ? 1.0 : 0.0)
Text(messageText ?? titleText)
.font(.appSubheadline)
.foregroundColor(titleColor)
Spacer()
}
}
public var titleBox: some View {
VStack(alignment: .trailing) {
Button(action: {}, label: {
Text("Edit")
.font(.appFootnote)
.padding(.trailing, 8)
.onTapGesture {
viewState = .editingTitle
}
}
} else {
Color.appButtonBackground
.aspectRatio(contentMode: .fill)
.frame(width: 61, height: 61)
}
})
.disabled(viewState == .editingTitle)
.opacity(viewState == .editingTitle ? 0.0 : 1.0)
VStack(alignment: .leading) {
Text(viewModel.title ?? "")
.lineLimit(1)
.foregroundColor(.appGrayTextContrast)
.font(Font.system(size: 15, weight: .semibold))
Text(viewModel.url ?? "")
.lineLimit(1)
.foregroundColor(.appGrayText)
.font(Font.system(size: 12, weight: .regular))
if viewState != .editingTitle {
Text(self.viewModel.title)
.font(.appSubheadline)
.foregroundColor(.appGrayTextContrast)
.frame(maxWidth: .infinity, alignment: .leading)
Spacer()
Text(self.viewModel.url ?? "")
.font(.appFootnote)
.foregroundColor(.appGrayText)
.frame(maxWidth: .infinity, alignment: .leading)
} else {}
}
Spacer()
VStack {
.frame(maxWidth: .infinity, maxHeight: 60)
.padding()
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.appGrayBorder, lineWidth: 1)
)
}
}
var labelsSection: some View {
HStack {
if viewState != .editingLabels {
ZStack {
Circle()
.foregroundColor(Color.blue)
.frame(width: 34, height: 34)
Image(systemName: "tag")
.font(.appCallout)
.frame(width: 34, height: 34)
}
.padding(.trailing, 8)
VStack {
Text("Labels")
.font(.appSubheadline)
.foregroundColor(Color.appGrayTextContrast)
.frame(maxWidth: .infinity, alignment: .leading)
let labelCount = labelsViewModel.selectedLabels.count
Text(labelCount > 0 ?
"\(labelCount) label\(labelCount > 1 ? "s" : "") selected"
: "Add labels to your saved link")
.font(.appFootnote)
.foregroundColor(Color.appGrayText)
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
Image(systemName: cloudIconName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 12, height: 12, alignment: .trailing)
.foregroundColor(cloudIconColor)
// .padding(.trailing, 6)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 8))
Image(systemName: "chevron.right")
.font(.appCallout)
} else {
VStack {
ScrollView {
LabelsMasonaryView(labels: labelsViewModel.labels,
selectedLabels: labelsViewModel.selectedLabels,
onLabelTap: onLabelTap)
}.background(Color.appButtonBackground)
.cornerRadius(8)
Button(
action: { labelsViewModel.showCreateLabelModal = true },
label: {
HStack {
Spacer()
Image(systemName: "plus")
Text("Create label")
Spacer()
}
}
).buttonStyle(RoundedRectButtonStyle(color: .blue, textColor: .white))
}
}
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: viewState == .editingLabels ? .infinity : 60)
.background(Color.appButtonBackground)
.frame(maxWidth: .infinity, maxHeight: 61)
.cornerRadius(8)
}
var highlightSection: some View {
HStack {
if viewState != .viewingHighlight {
ZStack {
Circle()
.foregroundColor(Color.appBackground)
.frame(width: 34, height: 34)
Image(systemName: "highlighter")
.font(.appCallout)
.frame(width: 34, height: 34)
.foregroundColor(Color.black)
}
.padding(.trailing, 8)
VStack {
Text("Highlight")
.font(.appSubheadline)
.foregroundColor(Color.appGrayTextContrast)
.frame(maxWidth: .infinity, alignment: .leading)
Text(viewModel.highlightData != nil ?
viewModel.highlightData!.highlightText
: "Select text before saving to create highlight")
.font(.appFootnote)
.foregroundColor(Color.appGrayText)
.frame(maxWidth: .infinity, alignment: .leading)
}
Spacer()
Image(systemName: "chevron.right")
.font(.appCallout)
} else if let highlightText = self.viewModel.highlightData?.highlightText {
Text(highlightText)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.cornerRadius(8)
.padding(0)
}
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: viewState == .viewingHighlight ? .infinity : 60)
.background(Color.appButtonBackground)
.cornerRadius(8)
}
func onLabelTap(label: LinkedItemLabel, textChip _: TextChip) {
if let selectedIndex = labelsViewModel.selectedLabels.firstIndex(of: label) {
labelsViewModel.selectedLabels.remove(at: selectedIndex)
} else {
labelsViewModel.selectedLabels.append(label)
}
if let linkedItem = viewModel.linkedItem {
labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID, dataService: viewModel.services.dataService)
}
}
var primaryButtons: some View {
HStack {
Button(
action: { viewModel.handleReadNowAction(extensionContext: extensionContext) },
label: {
Label("Read Now", systemImage: "book")
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
)
.foregroundColor(.appGrayTextContrast)
.background(Color.appButtonBackground)
.frame(height: 52)
.cornerRadius(8)
Spacer(minLength: 8)
Button(
action: {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
},
label: {
Label("Read Later", systemImage: "text.book.closed.fill")
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
)
.foregroundColor(.black)
.background(Color.appBackground)
.frame(height: 52)
.cornerRadius(8)
}
}
var moreActionsMenu: some View {
Menu {
Button(
action: {},
label: {
Button(action: {}, label: { Label("Dismiss", systemImage: "arrow.down.to.line") })
}
)
Button(action: {
if let linkedItem = self.viewModel.linkedItem {
self.viewModel.setLinkArchived(dataService: self.viewModel.services.dataService,
objectID: linkedItem.objectID,
archived: true)
messageText = "Link Archived"
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
}, label: {
Label(
"Archive",
systemImage: "archivebox"
)
})
Button(
action: {
if let linkedItem = self.viewModel.linkedItem {
self.viewModel.removeLink(dataService: self.viewModel.services.dataService, objectID: linkedItem.objectID)
messageText = "Link Removed"
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
},
label: {
Label("Remove", systemImage: "trash")
}
)
} label: {
Text("More Actions")
.font(.appFootnote)
.foregroundColor(Color.blue)
.frame(maxWidth: .infinity)
.padding(8)
.padding(.bottom, 8)
}
}
var editingViewTitle: String {
switch viewState {
case .editingTitle:
return "Edit Title"
case .editingLabels:
return "Labels"
case .viewingHighlight:
return "Highlight"
default:
return ""
}
}
public var body: some View {
VStack(alignment: .leading) {
Text(titleText)
.foregroundColor(.appGrayTextContrast)
.font(Font.system(size: 17, weight: .semibold))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 23)
.padding(.bottom, 12)
VStack(alignment: .center) {
Capsule()
.fill(.gray)
.frame(width: 60, height: 4)
.padding(.top, 10)
Rectangle()
.foregroundColor(.appGrayText)
.frame(maxWidth: .infinity, maxHeight: 1)
.opacity(0.06)
.padding(.top, 0)
.padding(.bottom, 18)
previewCard
.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
if let item = viewModel.linkedItem {
ApplyLabelsListView(linkedItem: item)
if viewState == .mainView {
titleBar
.padding(.top, 10)
.padding(.bottom, 12)
} else {
ZStack {
Button(action: {
withAnimation {
if viewState == .editingLabels {
if let linkedItem = self.viewModel.linkedItem {
self.labelsViewModel.selectedLabels = previousLabels ?? []
self.labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID,
dataService: self.viewModel.services.dataService)
}
}
viewState = .mainView
}
}, label: { Text("Cancel") })
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(viewState == .viewingHighlight ? 0.0 : 1.0)
// Don't show viewState when viewing the highlight
Text(editingViewTitle).bold()
.frame(maxWidth: .infinity, alignment: .center)
Button(action: {
withAnimation {
viewState = .mainView
if viewState == .editingTitle {
if let linkedItem = self.viewModel.linkedItem {
viewModel.submitTitleEdit(dataService: self.viewModel.services.dataService,
itemID: linkedItem.unwrappedID,
title: self.viewModel.title,
description: linkedItem.description)
}
}
}
}, label: { Text("Done").bold() })
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(8)
.padding(.bottom, 4)
}
if viewState == .mainView {
titleBox
}
if viewState == .editingTitle {
ScrollView(showsIndicators: false) {
VStack(alignment: .center, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
TextEditor(text: $viewModel.title)
.lineSpacing(6)
.accentColor(.appGraySolid)
.foregroundColor(.appGrayTextContrast)
.font(.appSubheadline)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.appGrayBorder, lineWidth: 1)
.background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground))
)
.frame(height: 100)
.focused($focusedField, equals: .titleEditor)
.task {
self.focusedField = .titleEditor
}
}
}
.padding(8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Spacer()
}
HStack {
Button(
action: { viewModel.handleReadNowAction(extensionContext: extensionContext) },
label: { Text("Read Now").frame(maxWidth: .infinity) }
)
.buttonStyle(RoundedRectButtonStyle())
Button(
action: {
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
},
label: {
Text("Read Later")
.frame(maxWidth: .infinity)
}
)
.buttonStyle(RoundedRectButtonStyle())
if viewState != .editingTitle {
if viewState != .viewingHighlight {
labelsSection
.onTapGesture {
withAnimation {
previousLabels = self.labelsViewModel.selectedLabels
viewState = .editingLabels
}
}
}
if viewState != .editingLabels {
highlightSection
.onTapGesture {
withAnimation {
viewState = .viewingHighlight
}
}
}
}
Spacer()
if viewState == .mainView {
Divider()
.padding(.bottom, 20)
primaryButtons
moreActionsMenu
}
.padding(.horizontal)
.padding(.bottom)
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.padding(.horizontal, 16)
.onAppear {
viewModel.savePage(extensionContext: extensionContext)
}
.sheet(isPresented: $labelsViewModel.showCreateLabelModal) {
CreateLabelView(viewModel: labelsViewModel)
}
.environmentObject(viewModel.services.dataService)
.task {
await labelsViewModel.loadLabelsFromStore(dataService: viewModel.services.dataService)
}
}
}

View file

@ -14,10 +14,6 @@
@State var showVoiceSheet = false
@State var tabIndex: Int = 0
var isPresented: Bool {
audioController.itemAudioProperties != nil && audioController.state != .stopped
}
var playPauseButtonImage: String {
switch audioController.state {
case .playing:
@ -146,7 +142,7 @@
+
Text(audioController.unreadText)
.font(.textToSpeechRead.leading(.loose))
.foregroundColor(Color.appGrayText)
.foregroundColor(audioController.useUltraRealisticVoices ? Color.appGrayTextContrast : Color.appGrayText)
}
}
}
@ -345,7 +341,7 @@
}
public var body: some View {
if let itemAudioProperties = self.audioController.itemAudioProperties, isPresented {
if let itemAudioProperties = self.audioController.itemAudioProperties {
playerContent(itemAudioProperties)
.tint(.appGrayTextContrast)
} else {

View file

@ -20,10 +20,6 @@
self.presentingView = AnyView(presentingView)
}
var isPresented: Bool {
audioController.itemAudioProperties != nil && audioController.state != .stopped
}
var playPauseButtonImage: String {
switch audioController.state {
case .playing:
@ -158,7 +154,7 @@
public var body: some View {
ZStack(alignment: .center) {
presentingView
if let itemAudioProperties = self.audioController.itemAudioProperties, isPresented {
if let itemAudioProperties = self.audioController.itemAudioProperties {
ZStack(alignment: .bottom) {
Color.systemBackground.edgesIgnoringSafeArea(.bottom)
.frame(height: expanded ? 0 : 88, alignment: .bottom)
@ -172,6 +168,9 @@
}
}
}
}.alert("There was an error playing back your audio.",
isPresented: $audioController.playbackError) {
Button("Dismiss", role: .none) {}
}
}
}

View file

@ -215,56 +215,118 @@ import Views
@ObservedObject var viewModel: HomeFeedViewModel
var filtersHeader: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
if viewModel.searchTerm.count > 0 {
TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) {
viewModel.searchTerm = ""
GeometryReader { reader in
ScrollView(.horizontal, showsIndicators: false) {
HStack {
if viewModel.searchTerm.count > 0 {
TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) {
viewModel.searchTerm = ""
}.frame(maxWidth: reader.size.width * 0.66)
} else {
Menu(
content: {
ForEach(LinkedItemFilter.allCases, id: \.self) { filter in
Button(filter.displayName, action: { viewModel.appliedFilter = filter.rawValue })
}
},
label: {
TextChipButton.makeMenuButton(
title: LinkedItemFilter(rawValue: viewModel.appliedFilter)?.displayName ?? "Filter"
)
}
)
}
} else {
Menu(
content: {
ForEach(LinkedItemFilter.allCases, id: \.self) { filter in
Button(filter.displayName, action: { viewModel.appliedFilter = filter.rawValue })
ForEach(LinkedItemSort.allCases, id: \.self) { sort in
Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue })
}
},
label: {
TextChipButton.makeMenuButton(
title: LinkedItemFilter(rawValue: viewModel.appliedFilter)?.displayName ?? "Filter"
title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort"
)
}
)
}
Menu(
content: {
ForEach(LinkedItemSort.allCases, id: \.self) { sort in
Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue })
TextChipButton.makeAddLabelButton {
viewModel.showLabelsSheet = true
}
ForEach(viewModel.selectedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) {
viewModel.selectedLabels.removeAll { $0.id == label.id }
}
}
ForEach(viewModel.negatedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) {
viewModel.negatedLabels.removeAll { $0.id == label.id }
}
}
Spacer()
}
.padding(0)
}
.listRowSeparator(.hidden)
}
}
func menuItems(for item: LinkedItem) -> some View {
Group {
if (item.highlights?.count ?? 0) > 0 {
Button(
action: { viewModel.itemForHighlightsView = item },
label: { Label("View Highlights & Notes", systemImage: "highlighter") }
)
}
Button(
action: { viewModel.itemUnderTitleEdit = item },
label: { Label("Edit Title/Description", systemImage: "textbox") }
)
Button(
action: { viewModel.itemUnderLabelEdit = item },
label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
)
Button(action: {
withAnimation(.linear(duration: 0.4)) {
viewModel.setLinkArchived(
dataService: dataService,
objectID: item.objectID,
archived: !item.isArchived
)
}
}, label: {
Label(
item.isArchived ? "Unarchive" : "Archive",
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
)
})
Button(
action: {
itemToRemove = item
confirmationShown = true
},
label: {
Label("Remove Item", systemImage: "trash")
}
).tint(.red)
if FeatureFlag.enableSnooze {
Button {
viewModel.itemToSnoozeID = item.id
viewModel.snoozePresented = true
} label: {
Label { Text("Snooze") } icon: { Image.moon }
}
}
if let author = item.author {
Button(
action: {
viewModel.searchTerm = "author:\"\(author)\""
},
label: {
TextChipButton.makeMenuButton(
title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort"
)
Label(String("More by \(author)"), systemImage: "person")
}
)
TextChipButton.makeAddLabelButton {
viewModel.showLabelsSheet = true
}
ForEach(viewModel.selectedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) {
viewModel.selectedLabels.removeAll { $0.id == label.id }
}
}
ForEach(viewModel.negatedLabels, id: \.self) { label in
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) {
viewModel.negatedLabels.removeAll { $0.id == label.id }
}
}
Spacer()
}
.padding(0)
}
.listRowSeparator(.hidden)
}
var body: some View {
@ -283,68 +345,15 @@ import Views
}
List {
if viewModel.items.count > 0 || viewModel.searchTerm.count > 0 {
filtersHeader
}
filtersHeader
ForEach(viewModel.items) { item in
FeedCardNavigationLink(
item: item,
viewModel: viewModel
)
.contextMenu {
Button(
action: { viewModel.itemForHighlightsView = item },
label: { Label("View Highlights & Notes", systemImage: "highlighter") }
)
Button(
action: { viewModel.itemUnderTitleEdit = item },
label: { Label("Edit Title/Description", systemImage: "textbox") }
)
Button(
action: { viewModel.itemUnderLabelEdit = item },
label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
)
Button(action: {
withAnimation(.linear(duration: 0.4)) {
viewModel.setLinkArchived(
dataService: dataService,
objectID: item.objectID,
archived: !item.isArchived
)
}
}, label: {
Label(
item.isArchived ? "Unarchive" : "Archive",
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
)
})
Button(
action: {
itemToRemove = item
confirmationShown = true
},
label: {
Label("Remove Item", systemImage: "trash")
}
).tint(.red)
if FeatureFlag.enableSnooze {
Button {
viewModel.itemToSnoozeID = item.id
viewModel.snoozePresented = true
} label: {
Label { Text("Snooze") } icon: { Image.moon }
}
}
if let author = item.author {
Button(
action: {
viewModel.searchTerm = "author:\"\(author)\""
},
label: {
Label(String("More by \(author)"), systemImage: "person")
}
)
}
menuItems(for: item)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !item.isArchived {
@ -428,8 +437,6 @@ import Views
viewModel.itemUnderLabelEdit = item
case .editTitle:
viewModel.itemUnderTitleEdit = item
case .downloadAudio:
viewModel.downloadAudio(audioController: audioController, item: item)
}
}

View file

@ -50,7 +50,7 @@ import Views
)
Button(
action: { viewModel.itemUnderLabelEdit = item },
label: { Label("Edit Labels", systemImage: "tag") }
label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
)
Button(action: {
withAnimation(.linear(duration: 0.4)) {

View file

@ -36,10 +36,6 @@ import Views
@AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilter = LinkedItemFilter.inbox.rawValue
@AppStorage(UserDefaultKey.lastItemSyncTime.rawValue) var lastItemSyncTime = DateFormatter.formatterISO8601.string(
from: Date(timeIntervalSinceReferenceDate: 0)
)
func handleReaderItemNotification(objectID: NSManagedObjectID, dataService: DataService) {
// Pop the current selected item if needed
if selectedItem != nil, selectedItem?.objectID != objectID {
@ -91,32 +87,47 @@ import Views
items.insert(item, at: 0)
}
func loadItems(dataService: DataService, audioController: AudioController, isRefresh: Bool) async {
let syncStartTime = Date()
let thisSearchIdx = searchIdx
searchIdx += 1
isLoading = true
showLoadingBar = true
func loadCurrentViewer(dataService: DataService) async {
// Cache the viewer
if dataService.currentViewer == nil {
Task { _ = try? await dataService.fetchViewer() }
_ = try? await dataService.fetchViewer()
}
}
// Fetch labels if none are available locally
func loadLabels(dataService: DataService) async {
let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
fetchRequest.fetchLimit = 1
if (try? dataService.viewContext.count(for: fetchRequest)) == 0 {
_ = try? await dataService.labels()
}
}
// Sync items if necessary
let lastSyncDate = dateFormatter.date(from: lastItemSyncTime) ?? Date(timeIntervalSinceReferenceDate: 0)
func syncItems(dataService: DataService, syncStartTime: Date) async {
let lastSyncDate = dateFormatter.date(from: dataService.lastItemSyncTime) ?? Date(timeIntervalSinceReferenceDate: 0)
let syncResult = try? await dataService.syncLinkedItems(since: lastSyncDate, cursor: nil)
if syncResult != nil {
lastItemSyncTime = dateFormatter.string(from: syncStartTime)
dataService.lastItemSyncTime = dateFormatter.string(from: syncStartTime)
}
// If possible start prefetching new pages in the background
if let itemIDs = syncResult?.updatedItemIDs,
let username = dataService.currentViewer?.username,
itemIDs.count > 0
{
Task.detached(priority: .background) {
await dataService.prefetchPages(itemIDs: itemIDs, username: username)
}
}
}
func loadSearchQuery(dataService: DataService, isRefresh: Bool) async {
let thisSearchIdx = searchIdx
searchIdx += 1
if thisSearchIdx > 0, thisSearchIdx <= receivedIdx {
return
}
let queryResult = try? await dataService.loadLinkedItems(
@ -125,15 +136,6 @@ import Views
cursor: isRefresh ? nil : cursor
)
// Search results aren't guaranteed to return in order so this
// will discard old results that are returned while a user is typing.
// For example if a user types 'Canucks', often the search results
// for 'C' are returned after 'Canucks' because it takes the backend
// much longer to compute.
if thisSearchIdx > 0, thisSearchIdx <= receivedIdx {
return
}
if let queryResult = queryResult {
let newItems: [LinkedItem] = {
var itemObjects = [LinkedItem]()
@ -159,30 +161,39 @@ import Views
cursor = queryResult.cursor
if let username = dataService.currentViewer?.username {
await dataService.prefetchPages(itemIDs: newItems.map(\.unwrappedID), username: username)
// Only preload the first item in the list. We are doing this during the beta
// because it will kick off the user's future items being automatically transcribed.
// This happens because when an article is saved, we check if the user has a recent
// listen. If they do, we will automatically transcribe their message.
if let first = newItems.filter({ !$0.isPDF }).first?.id {
_ = await audioController.preload(itemIDs: [first])
}
}
} else {
updateFetchController(dataService: dataService)
}
}
func loadItems(dataService: DataService, audioController _: AudioController, isRefresh: Bool) async {
let syncStartTime = Date()
let start = CFAbsoluteTimeGetCurrent()
isLoading = true
showLoadingBar = true
await withTaskGroup(of: Void.self) { group in
group.addTask { await self.loadCurrentViewer(dataService: dataService) }
group.addTask { await self.loadLabels(dataService: dataService) }
group.addTask { await self.syncItems(dataService: dataService, syncStartTime: syncStartTime) }
await group.waitForAll()
}
if searchTerm.replacingOccurrences(of: " ", with: "").isEmpty {
updateFetchController(dataService: dataService)
if appliedFilter != LinkedItemFilter.inbox.rawValue {
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
}
} else {
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
}
isLoading = false
showLoadingBar = false
}
func downloadAudio(audioController: AudioController, item: LinkedItem) {
Snackbar.show(message: "Downloading Offline Audio")
Task {
let downloaded = await audioController.downloadForOffline(itemID: item.unwrappedID)
Snackbar.show(message: downloaded ? "Audio file downloaded" : "Error downloading audio")
}
}
private var fetchRequest: NSFetchRequest<Models.LinkedItem> {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()

View file

@ -0,0 +1,95 @@
//
// LabelsMasonaryView.swift
//
//
// Created by Jackson Harper on 11/9/22.
//
import Foundation
import SwiftUI
import Models
import Views
struct LabelsMasonaryView: View {
// var allLabels: [LinkedItemLabel]
// var selectedLabels: [LinkedItemLabel]
var onLabelTap: (LinkedItemLabel, TextChip) -> Void
var iteration = UUID().uuidString
@State private var totalHeight = CGFloat.zero
private var labelItems: [(label: LinkedItemLabel, selected: Bool)]
init(labels allLabels: [LinkedItemLabel],
selectedLabels: [LinkedItemLabel],
onLabelTap: @escaping (LinkedItemLabel, TextChip) -> Void)
{
self.onLabelTap = onLabelTap
let selected = selectedLabels.map { (label: $0, selected: true) }
let unselected = allLabels.filter { !selectedLabels.contains($0) }.map { (label: $0, selected: false) }
labelItems = (selected + unselected).sorted(by: { left, right in
(left.label.name ?? "") < (right.label.name ?? "")
})
}
var body: some View {
VStack {
GeometryReader { geometry in
self.generateContent(in: geometry)
}
}
.frame(height: totalHeight)
}
private func generateContent(in geom: GeometryProxy) -> some View {
var width = CGFloat.zero
var height = CGFloat.zero
return ZStack(alignment: .topLeading) {
ForEach(self.labelItems, id: \.label.self) { label in
self.item(for: label)
.padding([.horizontal, .vertical], 6)
.alignmentGuide(.leading, computeValue: { dim in
if abs(width - dim.width) > geom.size.width {
width = 0
height -= dim.height
}
let result = width
if label == self.labelItems.last! {
width = 0 // last item
} else {
width -= dim.width
}
return result
})
.alignmentGuide(.top, computeValue: { _ in
let result = height
if label == self.labelItems.last! {
height = 0 // last item
}
return result
})
}
}
.background(viewHeightReader($totalHeight))
}
private func item(for item: (label: LinkedItemLabel, selected: Bool)) -> some View {
let chip = TextChip(feedItemLabel: item.label, negated: false, checked: item.selected) { chip in
onLabelTap(item.label, chip)
}
return chip
}
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
GeometryReader { geometry -> Color in
let rect = geometry.frame(in: .local)
DispatchQueue.main.async {
binding.wrappedValue = rect.size.height
}
return .clear
}
}
}

View file

@ -2,10 +2,15 @@
import Models
import Services
import SwiftUI
import Utils
import Views
struct TextToSpeechVoiceSelectionView: View {
@EnvironmentObject var audioController: AudioController
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = TextToSpeechVoiceSelectionViewModel()
let language: VoiceLanguage
let showLanguageChanger: Bool
@ -17,55 +22,140 @@
var body: some View {
Group {
Form {
if showLanguageChanger {
Section("Language") {
NavigationLink(destination: TextToSpeechLanguageView().navigationTitle("Language")) {
Text(audioController.currentVoiceLanguage.name)
if FeatureFlag.enableUltraRealisticVoices, language.key == "en" {
if viewModel.waitingForRealisticVoices {
HStack {
Text("Signing up for beta")
Spacer()
ProgressView()
}
} else {
Toggle("Use Ultra Realistic Voices", isOn: $viewModel.realisticVoicesToggle)
.accentColor(Color.green)
}
if !viewModel.waitingForRealisticVoices, !audioController.ultraRealisticFeatureKey.isEmpty {
Text("You are in the ultra realistic voices beta. During the beta you can listen to 10,000 words of audio per day.")
.multilineTextAlignment(.leading)
} else if audioController.ultraRealisticFeatureRequested {
Text("Your request to join the ultra realistic voices demo has been received. You will be informed by email when a spot is available.")
.multilineTextAlignment(.leading)
} else {
Text("Ultra realistic voices are currently in limited beta. Enabling the feature will add you to the beta queue.")
.multilineTextAlignment(.leading)
}
}
innerBody
if audioController.useUltraRealisticVoices {
ultraRealisticVoices
} else {
if showLanguageChanger {
Section("Language") {
NavigationLink(destination: TextToSpeechLanguageView().navigationTitle("Language")) {
Text(audioController.currentVoiceLanguage.name)
}
}
}
standardVoices
}
}
}
.navigationTitle("Choose a Voice")
.onAppear {
viewModel.realisticVoicesToggle = (audioController.useUltraRealisticVoices && !audioController.ultraRealisticFeatureKey.isEmpty)
}.onChange(of: viewModel.realisticVoicesToggle) { value in
if value, audioController.ultraRealisticFeatureKey.isEmpty {
// User wants to sign up
viewModel.waitingForRealisticVoices = true
Task {
await viewModel.requestUltraRealisticFeatureAccess(
dataService: self.dataService,
audioController: audioController
)
}
} else if value, !audioController.ultraRealisticFeatureKey.isEmpty {
audioController.useUltraRealisticVoices = true
} else if !value {
audioController.useUltraRealisticVoices = false
}
}
}
private var innerBody: some View {
private var standardVoices: some View {
ForEach(language.categories, id: \.self) { category in
Section(category.rawValue) {
ForEach(audioController.voiceList?.filter { $0.category == category } ?? [], id: \.key.self) { voice in
HStack {
// Voice samples are not working yet
// Button(action: {
// audioController.playVoiceSample(voice: voice.key)
// }) {
// Image(systemName: "play.circle").font(.appTitleTwo)
// }
// .buttonStyle(PlainButtonStyle())
Button(action: {
audioController.setPreferredVoice(voice.key, forLanguage: language.key)
audioController.currentVoice = voice.key
}) {
HStack {
Text(voice.name)
Spacer()
if voice.selected {
if audioController.isPlaying, audioController.isLoading {
ProgressView()
} else {
Image(systemName: "checkmark")
}
}
}
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
}
voiceRow(for: voice)
}
}
}
}
private var ultraRealisticVoices: some View {
ForEach([VoiceCategory.enUS, VoiceCategory.enCA, VoiceCategory.enUK], id: \.self) { category in
Section(category.rawValue) {
ForEach(audioController.realisticVoiceList?.filter { $0.category == category } ?? [], id: \.key.self) { voice in
voiceRow(for: voice)
}
}
}
}
func voiceRow(for voice: VoiceItem) -> some View {
HStack {
Button(action: {
if audioController.isPlayingSample(voice: voice.key) {
viewModel.playbackSample = nil
audioController.stopVoiceSample()
} else {
viewModel.playbackSample = voice.key
audioController.playVoiceSample(voice: voice.key)
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { timer in
let playing = audioController.isPlayingSample(voice: voice.key)
if playing {
viewModel.playbackSample = voice.key
} else if !playing {
// If the playback sample is something else, its taken ownership
// of the value so we just ignore it and shut down our timer.
if viewModel.playbackSample == voice.key {
viewModel.playbackSample = nil
}
timer.invalidate()
}
}
}
}, label: {
if viewModel.playbackSample == voice.key {
Image(systemName: "stop.circle")
.font(.appTitleTwo)
.padding(.trailing, 16)
} else {
Image(systemName: "play.circle")
.font(.appTitleTwo)
.padding(.trailing, 16)
}
})
Button(action: {
audioController.setPreferredVoice(voice.key, forLanguage: language.key)
audioController.currentVoice = voice.key
}, label: {
HStack {
Text(voice.name)
Spacer()
if voice.selected {
if audioController.isPlaying, audioController.isLoading {
ProgressView()
} else {
Image(systemName: "checkmark")
}
}
}
.contentShape(Rectangle())
})
.buttonStyle(PlainButtonStyle())
}
}
}
#endif

View file

@ -0,0 +1,55 @@
//
// TextToSpeechVoiceSelectionViewModel.swift
//
//
// Created by Jackson Harper on 11/10/22.
//
import CoreData
import Foundation
import Models
import Services
import SwiftUI
import Views
@MainActor final class TextToSpeechVoiceSelectionViewModel: ObservableObject {
@Published var playbackSample: String?
@Published var realisticVoicesToggle: Bool = false
@Published var waitingForRealisticVoices: Bool = false
func requestUltraRealisticFeatureAccess(
dataService: DataService,
audioController: AudioController
) async {
do {
let feature = try await dataService.optInFeature(name: "ultra-realistic-voice")
DispatchQueue.main.async {
if let feature = feature {
audioController.useUltraRealisticVoices = true
audioController.ultraRealisticFeatureRequested = true
audioController.ultraRealisticFeatureKey = feature.granted ? feature.token : ""
if feature.granted, !Voices.isUltraRealisticVoice(audioController.currentVoice) {
// Attempt to set to an ultra voice
if let voice = Voices.UltraPairs.first {
audioController.currentVoice = voice.firstKey
}
}
self.realisticVoicesToggle = true
} else {
audioController.useUltraRealisticVoices = false
audioController.ultraRealisticFeatureKey = ""
audioController.ultraRealisticFeatureRequested = false
self.realisticVoicesToggle = false
}
self.waitingForRealisticVoices = false
}
} catch {
print("ERROR OPTING INTO FEATURE", error)
audioController.useUltraRealisticVoices = false
realisticVoicesToggle = false
waitingForRealisticVoices = false
audioController.ultraRealisticFeatureRequested = false
Snackbar.show(message: "Error signing up for beta. Please try again.")
}
}
}

View file

@ -0,0 +1,101 @@
import Models
import SwiftUI
import Utils
import Views
import WebKit
struct HighlightViewer: PlatformViewRepresentable {
let highlightData: HighlightData
func makeCoordinator() -> WebReaderCoordinator {
WebReaderCoordinator()
}
private func makePlatformView(context: Context) -> WKWebView {
let webView = WebViewManager.shared()
let contentController = WKUserContentController()
webView.navigationDelegate = context.coordinator
webView.configuration.userContentController = contentController
webView.configuration.userContentController.removeAllScriptMessageHandlers()
#if os(iOS)
webView.isOpaque = false
webView.backgroundColor = .clear
webView.scrollView.delegate = context.coordinator
webView.scrollView.contentInset.top = readerViewNavBarHeight
webView.scrollView.verticalScrollIndicatorInsets.top = readerViewNavBarHeight
webView.configuration.userContentController.add(webView, name: "viewerAction")
#else
webView.setValue(false, forKey: "drawsBackground")
#endif
for action in WebViewAction.allCases {
webView.configuration.userContentController.add(context.coordinator, name: action.rawValue)
}
webView.configuration.userContentController.addScriptMessageHandler(
context.coordinator, contentWorld: .page, name: "articleAction"
)
loadContent(webView: webView)
return webView
}
private func updatePlatformView(_: WKWebView, context _: Context) {
// If the webview had been terminated `needsReload` will have been set to true
// Or if the articleContent value has changed then it's id will be different from the coordinator's
// if context.coordinator.needsReload {
// loadContent(webView: webView)
// context.coordinator.needsReload = false
// return
// }
}
private func loadContent(webView: WKWebView) {
let themeKey = ThemeManager.currentThemeName
let content = """
<!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-highlight' class="highlight">
\(highlightData.highlightHTML)
</div>
</body>
</html>
"""
webView.loadHTMLString(content, baseURL: ViewsPackage.resourceURL)
}
}
#if os(iOS)
extension HighlightViewer {
func makeUIView(context: Context) -> WKWebView {
makePlatformView(context: context)
}
func updateUIView(_ webView: WKWebView, context: Context) {
updatePlatformView(webView, context: context)
}
}
#else
extension WebReader {
func makeNSView(context: Context) -> WKWebView {
makePlatformView(context: context)
}
func updateNSView(_ webView: WKWebView, context: Context) {
updatePlatformView(webView, context: context)
}
}
#endif

View file

@ -153,6 +153,60 @@ struct WebReaderContainerView: View {
}.foregroundColor(.appGrayTextContrast)
}
func menuItems(for item: LinkedItem) -> some View {
let hasLabels = item.labels?.count == 0
let hasHighlights = (item.highlights?.count ?? 0) > 0
return Group {
if hasHighlights {
Button(
action: { showHighlightsView = true },
label: { Label("View Highlights & Notes", systemImage: "highlighter") }
)
}
Button(
action: { showTitleEdit = true },
label: { Label("Edit Title/Description", systemImage: "textbox") }
)
Button(
action: editLabels,
label: { Label(hasLabels ? "Edit Labels" : "Add Labels", systemImage: "tag") }
)
Button(
action: {
archive()
},
label: {
Label(
item.isArchived ? "Unarchive" : "Archive",
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
)
}
)
Button(
action: {
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0)
},
label: { Label("Reset Read Location", systemImage: "arrow.counterclockwise.circle") }
)
Button(
action: {
viewModel.downloadAudio(audioController: audioController, item: item)
},
label: { Label("Download Audio", systemImage: "icloud.and.arrow.down") }
)
if viewModel.hasOriginalUrl(item) {
Button(
action: share,
label: { Label("Share Original", systemImage: "square.and.arrow.up") }
)
}
Button(
action: delete,
label: { Label("Delete", systemImage: "trash") }
)
}
}
var navBar: some View {
HStack(alignment: .center) {
#if os(iOS)
@ -183,49 +237,7 @@ struct WebReaderContainerView: View {
#endif
Menu(
content: {
Group {
Button(
action: { showHighlightsView = true },
label: { Label("View Highlights & Notes", systemImage: "highlighter") }
)
Button(
action: { showTitleEdit = true },
label: { Label("Edit Title/Description", systemImage: "textbox") }
)
Button(
action: editLabels,
label: { Label("Edit Labels", systemImage: "tag") }
)
Button(
action: {
archive()
},
label: {
Label(
item.isArchived ? "Unarchive" : "Archive",
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
)
}
)
Button(
action: {
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0)
},
label: { Label("Reset Read Location", systemImage: "arrow.counterclockwise.circle") }
)
Button(
action: { /* viewModel.downloadAudio(audioController: audioController, item: item) */ },
label: { Label("Download Audio", systemImage: "icloud.and.arrow.down") }
)
Button(
action: share,
label: { Label("Share Original", systemImage: "square.and.arrow.up") }
)
Button(
action: delete,
label: { Label("Delete", systemImage: "trash") }
)
}
menuItems(for: item)
},
label: {
#if os(iOS)
@ -245,8 +257,9 @@ struct WebReaderContainerView: View {
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
.opacity(navBarVisibilityRatio)
.background(Color.systemBackground)
.alert("Are you sure?", isPresented: $showDeleteConfirmation) {
Button("Remove Link", role: .destructive) {
.alert("Are you sure you want to remove this item? All associated notes and highlights will be deleted.",
isPresented: $showDeleteConfirmation) {
Button("Remove Item", role: .destructive) {
Snackbar.show(message: "Link removed")
dataService.removeLink(objectID: item.objectID)
#if os(iOS)

View file

@ -1,6 +1,7 @@
import Models
import Services
import SwiftUI
import Views
import WebKit
struct SafariWebLink: Identifiable {
@ -12,6 +13,24 @@ struct SafariWebLink: Identifiable {
@Published var articleContent: ArticleContent?
@Published var errorMessage: String?
func hasOriginalUrl(_ item: LinkedItem) -> Bool {
if let pageURLString = item.pageURLString, let host = URL(string: pageURLString)?.host {
if host == "omnivore.app" {
return false
}
return true
}
return false
}
func downloadAudio(audioController: AudioController, item: LinkedItem) {
Snackbar.show(message: "Downloading Offline Audio")
Task {
let downloaded = await audioController.downloadForOffline(itemID: item.unwrappedID)
Snackbar.show(message: downloaded ? "Audio file downloaded" : "Error downloading audio")
}
}
func loadContent(dataService: DataService, username: String, itemID: String, retryCount: Int = 0) async {
errorMessage = nil

View file

@ -12,6 +12,16 @@ public struct LinkedItemQueryResult {
}
}
public struct LinkedItemSyncResult {
public let updatedItemIDs: [String]
public let cursor: String?
public init(updatedItemIDs: [String], cursor: String?) {
self.updatedItemIDs = updatedItemIDs
self.cursor = cursor
}
}
public struct LinkedItemAudioProperties {
public let itemID: String
public let objectID: NSManagedObjectID
@ -62,11 +72,22 @@ public extension LinkedItem {
return (pageURLString ?? "").hasSuffix("pdf")
}
func hideHost(_ host: String) -> Bool {
switch host {
case "storage.googleapis.com":
return true
case "omnivore.app":
return true
default:
return false
}
}
var publisherDisplayName: String? {
if let siteName = siteName {
return siteName
}
if let host = URL(string: publisherURLString ?? pageURLString ?? "")?.host, host != "storage.googleapis.com" {
if let host = URL(string: publisherURLString ?? pageURLString ?? "")?.host, !hideHost(host) {
return host
}
return nil

View file

@ -8,11 +8,26 @@ import UniformTypeIdentifiers
let URLREGEX = #"[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)"#
public struct HighlightData {
public let highlightHTML: String
public let highlightText: String
public static func make(dict: NSDictionary?) -> HighlightData? {
if let dict = dict,
let highlightHTML = dict["highlightHTML"] as? String,
let highlightText = dict["highlightText"] as? String
{
return HighlightData(highlightHTML: highlightHTML, highlightText: highlightText)
}
return nil
}
}
public struct PageScrapePayload {
public enum ContentType {
case none
case html(html: String, title: String?, iconURL: String?)
case pdf(localUrl: URL)
case html(html: String, title: String?, highlightData: HighlightData?)
}
public let url: String
@ -33,9 +48,9 @@ public struct PageScrapePayload {
self.contentType = .pdf(localUrl: localUrl)
}
init(url: String, title: String?, html: String, iconURL: String? = nil) {
init(url: String, title: String?, html: String, highlightData: HighlightData?) {
self.url = url
self.contentType = .html(html: html, title: title, iconURL: iconURL)
self.contentType = .html(html: html, title: title, highlightData: highlightData)
}
}
@ -302,7 +317,6 @@ private extension PageScrapePayload {
guard let url = results?["url"] as? String else { return nil }
let html = results?["originalHTML"] as? String
let title = results?["title"] as? String
let iconURL = results?["iconURL"] as? String
let contentType = results?["contentType"] as? String
// If we were not able to capture any HTML, treat this as a URL and
@ -318,7 +332,10 @@ private extension PageScrapePayload {
}
if let html = html {
return PageScrapePayload(url: url, title: title, html: html, iconURL: iconURL)
return PageScrapePayload(url: url,
title: title,
html: html,
highlightData: HighlightData.make(dict: results))
}
return PageScrapePayload(url: url)

View file

@ -27,172 +27,6 @@
case high
}
// Somewhat based on: https://github.com/neekeetab/CachingPlayerItem/blob/master/CachingPlayerItem.swift
class SpeechPlayerItem: AVPlayerItem {
let resourceLoaderDelegate = ResourceLoaderDelegate()
let session: AudioController
let speechItem: SpeechItem
var speechMarks: [SpeechMark]?
let completed: () -> Void
var observer: Any?
init(session: AudioController, speechItem: SpeechItem, completed: @escaping () -> Void) {
self.speechItem = speechItem
self.session = session
self.completed = completed
guard let fakeUrl = URL(string: "app.omnivore.speech://\(speechItem.localAudioURL.path).mp3") else {
fatalError("internal inconsistency")
}
let asset = AVURLAsset(url: fakeUrl)
asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main)
super.init(asset: asset, automaticallyLoadedAssetKeys: nil)
resourceLoaderDelegate.owner = self
self.observer = observe(\.status, options: [.new]) { item, _ in
if item.status == .readyToPlay {
let duration = CMTimeGetSeconds(item.duration)
item.session.updateDuration(forItem: item.speechItem, newDuration: duration)
}
}
NotificationCenter.default.addObserver(
forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
object: self, queue: OperationQueue.main
) { [weak self] _ in
guard let self = self else { return }
self.completed()
}
}
deinit {
observer = nil
resourceLoaderDelegate.session?.invalidateAndCancel()
}
open func download() {
if resourceLoaderDelegate.session == nil {
resourceLoaderDelegate.startDataRequest(with: speechItem.urlRequest)
}
}
@objc func playbackStalledHandler() {
print("playback stalled...")
}
class ResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {
var session: URLSession?
var mediaData: Data?
var pendingRequests = Set<AVAssetResourceLoadingRequest>()
weak var owner: SpeechPlayerItem?
func resourceLoader(_: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool
{
if owner == nil {
return true
}
if session == nil {
guard let initialUrl = owner?.speechItem.urlRequest else {
fatalError("internal inconsistency")
}
startDataRequest(with: initialUrl)
}
pendingRequests.insert(loadingRequest)
processPendingRequests()
return true
}
func startDataRequest(with _: URLRequest) {
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
session = URLSession(configuration: configuration)
Task {
guard let speechItem = self.owner?.speechItem else {
// This probably can't happen, but if it does, just returning should
// let AVPlayer try again.
print("No speech item found: ", self.owner?.speechItem)
return
}
// TODO: how do we want to propogate this and handle it in the player
let speechData = try? await SpeechSynthesizer.download(speechItem: speechItem, session: self.session)
DispatchQueue.main.async {
if speechData == nil {
self.session = nil
}
if let owner = self.owner, let speechData = speechData {
owner.speechMarks = speechData.speechMarks
}
self.mediaData = speechData?.audioData
self.processPendingRequests()
}
}
}
func resourceLoader(_: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
pendingRequests.remove(loadingRequest)
}
func processPendingRequests() {
let requestsFulfilled = Set<AVAssetResourceLoadingRequest>(pendingRequests.compactMap {
self.fillInContentInformationRequest($0.contentInformationRequest)
if self.haveEnoughDataToFulfillRequest($0.dataRequest!) {
$0.finishLoading()
return $0
}
return nil
})
// remove fulfilled requests from pending requests
_ = requestsFulfilled.map { self.pendingRequests.remove($0) }
}
func fillInContentInformationRequest(_ contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) {
contentInformationRequest?.contentType = UTType.mp3.identifier
if let mediaData = mediaData {
contentInformationRequest?.isByteRangeAccessSupported = true
contentInformationRequest?.contentLength = Int64(mediaData.count)
}
}
func haveEnoughDataToFulfillRequest(_ dataRequest: AVAssetResourceLoadingDataRequest) -> Bool {
let requestedOffset = Int(dataRequest.requestedOffset)
let requestedLength = dataRequest.requestedLength
let currentOffset = Int(dataRequest.currentOffset)
guard let songDataUnwrapped = mediaData,
songDataUnwrapped.count > currentOffset
else {
// Don't have any data at all for this request.
return false
}
let bytesToRespond = min(songDataUnwrapped.count - currentOffset, requestedLength)
let range = Range(uncheckedBounds: (currentOffset, currentOffset + bytesToRespond))
let dataToRespond = songDataUnwrapped.subdata(in: range)
dataRequest.respond(with: dataToRespond)
return songDataUnwrapped.count >= requestedLength + requestedOffset
}
deinit {
session?.invalidateAndCancel()
}
}
}
// swiftlint:disable all
public class AudioController: NSObject, ObservableObject, AVAudioPlayerDelegate {
@Published public var state: AudioControllerState = .stopped
@ -205,10 +39,13 @@
@Published public var duration: TimeInterval = 0
@Published public var timeElapsedString: String?
@Published public var durationString: String?
@Published public var voiceList: [(name: String, key: String, category: VoiceCategory, selected: Bool)]?
@Published public var voiceList: [VoiceItem]?
@Published public var realisticVoiceList: [VoiceItem]?
@Published public var textItems: [String]?
@Published public var playbackError: Bool = false
let dataService: DataService
var timer: Timer?
@ -219,11 +56,14 @@
var durations: [Double]?
var lastReadUpdate = 0.0
var samplePlayer: AVAudioPlayer?
public init(dataService: DataService) {
self.dataService = dataService
super.init()
self.voiceList = generateVoiceList()
self.realisticVoiceList = generateRealisticVoiceList()
}
deinit {
@ -277,11 +117,25 @@
}
}
public func generateVoiceList() -> [(name: String, key: String, category: VoiceCategory, selected: Bool)] {
public func stopWithError() {
stop()
playbackError = true
}
public func generateVoiceList() -> [VoiceItem] {
Voices.Pairs.flatMap { voicePair in
[
(name: voicePair.firstName, key: voicePair.firstKey, category: voicePair.category, selected: voicePair.firstKey == currentVoice),
(name: voicePair.secondName, key: voicePair.secondKey, category: voicePair.category, selected: voicePair.secondKey == currentVoice)
VoiceItem(name: voicePair.firstName, key: voicePair.firstKey, category: voicePair.category, selected: voicePair.firstKey == currentVoice),
VoiceItem(name: voicePair.secondName, key: voicePair.secondKey, category: voicePair.category, selected: voicePair.secondKey == currentVoice)
]
}.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
public func generateRealisticVoiceList() -> [VoiceItem] {
Voices.UltraPairs.flatMap { voicePair in
[
VoiceItem(name: voicePair.firstName, key: voicePair.firstKey, category: voicePair.category, selected: voicePair.firstKey == currentVoice),
VoiceItem(name: voicePair.secondName, key: voicePair.secondKey, category: voicePair.category, selected: voicePair.secondKey == currentVoice)
]
}.sorted { $0.name.lowercased() < $1.name.lowercased() }
}
@ -293,7 +147,7 @@
for itemID in itemIDs {
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document)
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document, speechAuthHeader: speechAuthHeader)
do {
try await synthesizer.preload()
return true
@ -307,7 +161,7 @@
public func downloadForOffline(itemID: String) async -> Bool {
if let document = try? await downloadSpeechFile(itemID: itemID, priority: .low) {
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document)
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document, speechAuthHeader: speechAuthHeader)
for item in synthesizer.createPlayerItems(from: 0) {
do {
_ = try await SpeechSynthesizer.download(speechItem: item, redownloadCached: true)
@ -419,6 +273,18 @@
@AppStorage(UserDefaultKey.textToSpeechPreloadEnabled.rawValue) public var preloadEnabled = false
@AppStorage(UserDefaultKey.textToSpeechUseUltraRealisticVoices.rawValue) public var useUltraRealisticVoices = false
@AppStorage(UserDefaultKey.textToSpeechUltraRealisticFeatureKey.rawValue) public var ultraRealisticFeatureKey: String = ""
@AppStorage(UserDefaultKey.textToSpeechUltraRealisticFeatureRequested.rawValue) public var ultraRealisticFeatureRequested: Bool = false
var speechAuthHeader: String? {
if Voices.isUltraRealisticVoice(currentVoice), !ultraRealisticFeatureKey.isEmpty {
return ultraRealisticFeatureKey
}
return nil
}
public var currentVoiceLanguage: VoiceLanguage {
Voices.Languages.first(where: { $0.key == currentLanguage }) ?? Voices.English
}
@ -458,6 +324,7 @@
set {
_currentVoice = newValue
voiceList = generateVoiceList()
realisticVoiceList = generateRealisticVoiceList()
var currentIdx = 0
var currentOffset = 0.0
@ -520,7 +387,7 @@
// Sometimes we get negatives
currentItemOffset = max(currentItemOffset, 0)
let idx = item.speechItem.audioIdx
let idx = currentAudioIndex // item.speechItem.audioIdx
let currentItem = document?.utterances[idx].text ?? ""
let currentReadIndex = currentItem.index(currentItem.startIndex, offsetBy: min(currentItemOffset, currentItem.count))
let lastItem = String(currentItem[..<currentReadIndex])
@ -554,7 +421,7 @@
DispatchQueue.main.async {
if let document = document {
let synthesizer = SpeechSynthesizer(appEnvironment: self.dataService.appEnvironment, networker: self.dataService.networker, document: document)
let synthesizer = SpeechSynthesizer(appEnvironment: self.dataService.appEnvironment, networker: self.dataService.networker, document: document, speechAuthHeader: self.speechAuthHeader)
self.setTextItems()
self.durations = synthesizer.estimatedDurations(forSpeed: self.playbackRate)
@ -586,9 +453,15 @@
public func playVoiceSample(voice: String) {
do {
if let url = Bundle.main.url(forResource: "tts-voice-sample-\(voice)", withExtension: "mp3") {
let player = try AVAudioPlayer(contentsOf: url, fileTypeHint: AVFileType.mp3.rawValue)
player.play()
pause()
if let url = Bundle(url: UtilsPackage.bundleURL)?.url(forResource: voice, withExtension: "mp3") {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: [])
samplePlayer = try AVAudioPlayer(contentsOf: url, fileTypeHint: AVFileType.mp3.rawValue)
if !(samplePlayer?.play() ?? false) {
throw BasicError.message(messageText: "Unable to playback audio")
}
} else {
NSNotification.operationFailed(message: "Error playing voice sample.")
}
@ -598,6 +471,20 @@
}
}
public func isPlayingSample(voice: String) -> Bool {
if let samplePlayer = self.samplePlayer, let url = Bundle(url: UtilsPackage.bundleURL)?.url(forResource: voice, withExtension: "mp3") {
return samplePlayer.url == url && samplePlayer.isPlaying
}
return false
}
public func stopVoiceSample() {
if let samplePlayer = self.samplePlayer {
samplePlayer.stop()
self.samplePlayer = nil
}
}
private func updateDurations(oldPlayback: Double, newPlayback: Double) {
if let oldDurations = durations {
durations = oldDurations.map { $0 * oldPlayback / newPlayback }
@ -684,10 +571,11 @@
if let player = player {
observer = player.observe(\.currentItem, options: [.new]) { _, _ in
self.currentAudioIndex = (player.currentItem as? SpeechPlayerItem)?.speechItem.audioIdx ?? 0
self.updateReadText()
}
}
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document)
let synthesizer = SpeechSynthesizer(appEnvironment: dataService.appEnvironment, networker: dataService.networker, document: document, speechAuthHeader: speechAuthHeader)
durations = synthesizer.estimatedDurations(forSpeed: playbackRate)
self.synthesizer = synthesizer
@ -696,9 +584,12 @@
func synthesizeFrom(start: Int, playWhenReady: Bool, atOffset: Double = 0.0) {
if let synthesizer = self.synthesizer, let items = self.synthesizer?.createPlayerItems(from: start) {
let prefetchQueue = OperationQueue()
prefetchQueue.maxConcurrentOperationCount = 5
for speechItem in items {
let isLast = speechItem.audioIdx == synthesizer.document.utterances.count - 1
let playerItem = SpeechPlayerItem(session: self, speechItem: speechItem) {
let playerItem = SpeechPlayerItem(session: self, prefetchQueue: prefetchQueue, speechItem: speechItem) {
if isLast {
self.player?.pause()
self.state = .reachedEnd
@ -731,6 +622,7 @@
}
public func unpause() {
stopVoiceSample()
if let player = player {
player.rate = Float(playbackRate)
state = .playing
@ -780,6 +672,14 @@
case .reset:
if let playerItem = player.currentItem as? SpeechPlayerItem {
let itemElapsed = playerItem.status == .readyToPlay ? CMTimeGetSeconds(playerItem.currentTime()) : 0
if itemElapsed >= CMTimeGetSeconds(playerItem.duration) + 0.5 {
// Occasionally AV wont send an event for a new item starting for ~3s, if this
// happens we can try to manually update the time
if playerItem.speechItem.audioIdx + 1 < (document?.utterances.count ?? 0) {
currentAudioIndex = playerItem.speechItem.audioIdx + 1
}
}
timeElapsed = durationBefore(playerIndex: playerItem.speechItem.audioIdx) + itemElapsed
timeElapsedString = formatTimeInterval(timeElapsed)

View file

@ -0,0 +1,76 @@
//
// PrefetchSpeechItemOperation.swift
//
//
// Created by Jackson Harper on 11/9/22.
//
import Foundation
import Models
import Utils
final class PrefetchSpeechItemOperation: Operation, URLSessionDelegate {
let speechItem: SpeechItem
let session: URLSession
enum State: Int {
case created
case started
case finished
}
init(speechItem: SpeechItem) {
self.speechItem = speechItem
self.state = .created
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
self.session = URLSession(configuration: configuration)
}
public var state: State = .created {
willSet {
willChangeValue(forKey: "isReady")
willChangeValue(forKey: "isExecuting")
willChangeValue(forKey: "isFinished")
willChangeValue(forKey: "isCancelled")
}
didSet {
didChangeValue(forKey: "isCancelled")
didChangeValue(forKey: "isFinished")
didChangeValue(forKey: "isExecuting")
didChangeValue(forKey: "isReady")
}
}
override var isAsynchronous: Bool {
true
}
override var isReady: Bool {
true
}
override var isExecuting: Bool {
self.state == .started
}
override var isFinished: Bool {
self.state == .finished
}
override func start() {
guard !isCancelled else { return }
state = .started
Task {
_ = try await SpeechSynthesizer.download(speechItem: speechItem, session: session)
state = .finished
}
}
override func cancel() {
session.invalidateAndCancel()
super.cancel()
}
}

View file

@ -0,0 +1,208 @@
//
// SpeechPlayerItem.swift
//
//
// Created by Jackson Harper on 11/9/22.
//
import AVFoundation
import Foundation
import Models
// Somewhat based on: https://github.com/neekeetab/CachingPlayerItem/blob/master/CachingPlayerItem.swift
class SpeechPlayerItem: AVPlayerItem {
let resourceLoaderDelegate = ResourceLoaderDelegate()
let session: AudioController
let speechItem: SpeechItem
var speechMarks: [SpeechMark]?
var prefetchOperation: PrefetchSpeechItemOperation?
let completed: () -> Void
var observer: Any?
init(session: AudioController, prefetchQueue: OperationQueue, speechItem: SpeechItem, completed: @escaping () -> Void) {
self.speechItem = speechItem
self.session = session
self.completed = completed
guard let fakeUrl = URL(string: "app.omnivore.speech://\(speechItem.localAudioURL.path).mp3") else {
fatalError("internal inconsistency")
}
let asset = AVURLAsset(url: fakeUrl)
asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.main)
super.init(asset: asset, automaticallyLoadedAssetKeys: nil)
resourceLoaderDelegate.owner = self
self.observer = observe(\.status, options: [.new]) { item, _ in
if item.status == .readyToPlay {
let duration = CMTimeGetSeconds(item.duration)
item.session.updateDuration(forItem: item.speechItem, newDuration: duration)
}
if item.status == .failed {
item.session.stopWithError()
}
}
NotificationCenter.default.addObserver(
forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
object: self, queue: OperationQueue.main
) { [weak self] _ in
guard let self = self else { return }
self.completed()
}
self.prefetchOperation = PrefetchSpeechItemOperation(speechItem: speechItem)
if let prefetchOperation = self.prefetchOperation {
prefetchQueue.addOperation(prefetchOperation)
}
}
deinit {
observer = nil
prefetchOperation?.cancel()
resourceLoaderDelegate.session?.invalidateAndCancel()
}
open func download() {
if resourceLoaderDelegate.session == nil {
resourceLoaderDelegate.startDataRequest(with: speechItem.urlRequest)
}
}
@objc func playbackStalledHandler() {
print("playback stalled...")
}
class ResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {
var session: URLSession?
var mediaData: Data?
var pendingRequests = Set<AVAssetResourceLoadingRequest>()
weak var owner: SpeechPlayerItem?
func resourceLoader(_: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool
{
if owner == nil {
return true
}
if session == nil {
guard let initialUrl = owner?.speechItem.urlRequest else {
fatalError("internal inconsistency")
}
startDataRequest(with: initialUrl)
}
pendingRequests.insert(loadingRequest)
processPendingRequests()
return true
}
func startDataRequest(with _: URLRequest) {
let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
session = URLSession(configuration: configuration)
Task {
guard let speechItem = self.owner?.speechItem else {
// This probably can't happen, but if it does, just returning should
DispatchQueue.main.async {
self.processPlaybackError(error: BasicError.message(messageText: "No speech item found."))
}
return
}
do {
let speechData = try await SpeechSynthesizer.download(speechItem: speechItem, session: self.session ?? URLSession.shared)
DispatchQueue.main.async {
if speechData == nil {
self.session = nil
self.processPlaybackError(error: BasicError.message(messageText: "Unable to download speech data."))
return
}
if let owner = self.owner, let speechData = speechData {
owner.speechMarks = speechData.speechMarks
}
self.mediaData = speechData?.audioData
self.processPendingRequests()
}
} catch URLError.cancelled {
print("cancelled request error being ignored")
} catch {
DispatchQueue.main.async {
self.processPlaybackError(error: error)
}
}
}
}
func resourceLoader(_: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
pendingRequests.remove(loadingRequest)
}
func processPendingRequests() {
let requestsFulfilled = Set<AVAssetResourceLoadingRequest>(pendingRequests.compactMap {
self.fillInContentInformationRequest($0.contentInformationRequest)
if self.haveEnoughDataToFulfillRequest($0.dataRequest!) {
$0.finishLoading()
return $0
}
return nil
})
// remove fulfilled requests from pending requests
_ = requestsFulfilled.map { self.pendingRequests.remove($0) }
}
func processPlaybackError(error: Error?) {
let requestsFulfilled = Set<AVAssetResourceLoadingRequest>(pendingRequests.compactMap {
$0.finishLoading(with: error)
return nil
})
_ = requestsFulfilled.map { self.pendingRequests.remove($0) }
}
func fillInContentInformationRequest(_ contentInformationRequest: AVAssetResourceLoadingContentInformationRequest?) {
contentInformationRequest?.contentType = UTType.mp3.identifier
if let mediaData = mediaData {
contentInformationRequest?.isByteRangeAccessSupported = true
contentInformationRequest?.contentLength = Int64(mediaData.count)
}
}
func haveEnoughDataToFulfillRequest(_ dataRequest: AVAssetResourceLoadingDataRequest) -> Bool {
let requestedOffset = Int(dataRequest.requestedOffset)
let requestedLength = dataRequest.requestedLength
let currentOffset = Int(dataRequest.currentOffset)
guard let songDataUnwrapped = mediaData,
songDataUnwrapped.count > currentOffset
else {
// Don't have any data at all for this request.
return false
}
let bytesToRespond = min(songDataUnwrapped.count - currentOffset, requestedLength)
let range = Range(uncheckedBounds: (currentOffset, currentOffset + bytesToRespond))
let dataToRespond = songDataUnwrapped.subdata(in: range)
dataRequest.respond(with: dataToRespond)
return songDataUnwrapped.count >= requestedLength + requestedOffset
}
deinit {
session?.invalidateAndCancel()
}
}
}

View file

@ -16,6 +16,7 @@ struct UtteranceRequest: Codable {
let voice: String
let language: String
let rate: String
let isUltraRealisticVoice: Bool
}
struct Utterance: Decodable {
@ -26,10 +27,12 @@ struct Utterance: Decodable {
public let wordCount: Double
func toSSML(document: SpeechDocument) throws -> Data? {
let usedVoice = voice ?? document.defaultVoice
let request = UtteranceRequest(text: text,
voice: voice ?? document.defaultVoice,
voice: usedVoice,
language: document.language,
rate: "1.1")
rate: "1.1",
isUltraRealisticVoice: Voices.isUltraRealisticVoice(usedVoice))
return try JSONEncoder().encode(request)
}
}
@ -78,11 +81,13 @@ struct SpeechSynthesizer {
let document: SpeechDocument
let appEnvironment: AppEnvironment
let networker: Networker
let speechAuthHeader: String?
init(appEnvironment: AppEnvironment, networker: Networker, document: SpeechDocument) {
init(appEnvironment: AppEnvironment, networker: Networker, document: SpeechDocument, speechAuthHeader: String?) {
self.appEnvironment = appEnvironment
self.networker = networker
self.document = document
self.speechAuthHeader = speechAuthHeader
}
func estimatedDurations(forSpeed speed: Double) -> [Double] {
@ -120,7 +125,7 @@ struct SpeechSynthesizer {
func createPlayerItems(from: Int) -> [SpeechItem] {
var result: [SpeechItem] = []
for idx in from ..< min(7, document.utterances.count) {
for idx in from ..< document.utterances.count {
let utterance = document.utterances[idx]
let voiceStr = utterance.voice ?? document.defaultVoice
let segmentStr = String(format: "%04d", arguments: [idx])
@ -156,34 +161,52 @@ struct SpeechSynthesizer {
request.setValue(value, forHTTPHeaderField: header)
}
if let speechAuthHeader = speechAuthHeader {
request.setValue(speechAuthHeader, forHTTPHeaderField: "Authorization")
}
return request
}
static func downloadData(session: URLSession, request: URLRequest) async throws -> Data {
do {
let result: (Data, URLResponse)? = try await session.data(for: request)
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
print("error: ", result?.1)
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
}
guard let data = result?.0 else {
throw BasicError.message(messageText: "audioFetch failed. no data received.")
}
return data
} catch URLError.cancelled {
print("cancled request error being ignored")
return Data()
} catch {
print("ERROR DOWNLOADING AUDIO DATA", error)
throw error
}
}
static func download(speechItem: SpeechItem,
redownloadCached: Bool = false,
session: URLSession? = URLSession.shared) async throws -> SynthesizeData?
session: URLSession = URLSession.shared) async throws -> SynthesizeData?
{
let decoder = JSONDecoder()
if !redownloadCached {
if let speechMarksData = try? Data(contentsOf: speechItem.localSpeechURL),
let speechMarks = try? decoder.decode([SpeechMark].self, from: speechMarksData),
let localData = try? Data(contentsOf: speechItem.localAudioURL)
{
if let localData = try? Data(contentsOf: speechItem.localAudioURL) {
var speechMarks: [SpeechMark]?
if let speechMarksData = try? Data(contentsOf: speechItem.localSpeechURL) {
speechMarks = try? decoder.decode([SpeechMark].self, from: speechMarksData)
}
return SynthesizeData(audioData: localData, speechMarks: speechMarks)
}
}
let request = speechItem.urlRequest
let result: (Data, URLResponse)? = try? await (session ?? URLSession.shared).data(for: request)
guard let httpResponse = result?.1 as? HTTPURLResponse, 200 ..< 300 ~= httpResponse.statusCode else {
print("error: ", result?.1 as Any)
throw BasicError.message(messageText: "audioFetch failed. no response or bad status code.")
}
guard let data = result?.0 else {
throw BasicError.message(messageText: "audioFetch failed. no data received.")
}
let data = try await downloadData(session: session, request: speechItem.urlRequest)
let tempPath = FileManager.default
.urls(for: .cachesDirectory, in: .userDomainMask)[0]
@ -204,8 +227,6 @@ struct SpeechSynthesizer {
try? FileManager.default.removeItem(at: speechItem.localAudioURL)
try FileManager.default.moveItem(at: tempPath, to: speechItem.localAudioURL)
let savedData = try? Data(contentsOf: speechItem.localAudioURL)
let encoder = JSONEncoder()
let speechMarksData = try encoder.encode(jsonData.speechMarks)
try speechMarksData.write(to: tempSMPath)
@ -214,6 +235,7 @@ struct SpeechSynthesizer {
return SynthesizeData(audioData: audioData, speechMarks: jsonData.speechMarks)
} catch {
print("ERROR DOWNLOADING SPEECH DATA:", error)
let errorMessage = "audioFetch failed. could not write MP3 data to disk"
throw BasicError.message(messageText: errorMessage)
}
@ -222,12 +244,12 @@ struct SpeechSynthesizer {
struct SynthesizeResult: Decodable {
let audioData: String
let speechMarks: [SpeechMark]
let speechMarks: [SpeechMark]?
}
struct SynthesizeData: Decodable {
let audioData: Data
let speechMarks: [SpeechMark]
let speechMarks: [SpeechMark]?
}
extension Data {

View file

@ -14,6 +14,13 @@ public struct VoiceLanguage {
public let categories: [VoiceCategory]
}
public struct VoiceItem {
public let name: String
public let key: String
public let category: VoiceCategory
public let selected: Bool
}
public enum VoiceCategory: String, CaseIterable {
case enUS = "English (US)"
case enAU = "English (Australia)"
@ -29,10 +36,10 @@ public enum VoiceCategory: String, CaseIterable {
}
public struct VoicePair {
let firstKey: String
public let firstKey: String
let secondKey: String
let firstName: String
public let firstName: String
let secondName: String
let language: String
@ -40,6 +47,12 @@ public struct VoicePair {
}
public enum Voices {
public static func isUltraRealisticVoice(_ voiceKey: String) -> Bool {
UltraPairs.contains(where: { voice in
voice.firstKey == voiceKey || voice.secondKey == voiceKey
})
}
public static let English = VoiceLanguage(key: "en",
name: "English",
defaultVoice: "en-US-ChristopherNeural",
@ -72,4 +85,20 @@ public enum Voices {
VoicePair(firstKey: "de-DE-ChristophNeural", secondKey: "de-DE-LouisaNeural", firstName: "Christoph", secondName: "Louisa", language: "de-DE", category: .deDE),
VoicePair(firstKey: "ja-JP-NanamiNeural", secondKey: "ja-JP-KeitaNeural", firstName: "Nanami", secondName: "Keita", language: "ja-JP", category: .jaJP)
]
public static let UltraPairs = [
VoicePair(firstKey: "Larry", secondKey: "Susan", firstName: "Larry", secondName: "Susan", language: "en-US", category: .enUS),
VoicePair(firstKey: "Jordan", secondKey: "William", firstName: "Jordan", secondName: "William", language: "en-US", category: .enUS),
VoicePair(firstKey: "Evelyn", secondKey: "Axel", firstName: "Evelyn", secondName: "Axel", language: "en-US", category: .enUS),
VoicePair(firstKey: "Nova", secondKey: "Owen", firstName: "Nova", secondName: "Owen", language: "en-US", category: .enUS),
VoicePair(firstKey: "Frankie", secondKey: "Natalie", firstName: "Frankie", secondName: "Natalie", language: "en-US", category: .enUS),
VoicePair(firstKey: "Daniel", secondKey: "Charlotte", firstName: "Daniel", secondName: "Charlotte", language: "en-CA", category: .enCA),
VoicePair(firstKey: "Lillian", secondKey: "Aurora", firstName: "Lillian", secondName: "Aurora", language: "en-UK", category: .enUK),
VoicePair(firstKey: "Oliver", secondKey: "Arthur", firstName: "Oliver", secondName: "Arthur", language: "en-UK", category: .enUK),
VoicePair(firstKey: "Frederick", secondKey: "Hunter", firstName: "Frederick", secondName: "Hunter", language: "en-UK", category: .enUK),
VoicePair(firstKey: "Nolan", secondKey: "Phoebe", firstName: "Nolan", secondName: "Phoebe", language: "en-UK", category: .enUK),
VoicePair(firstKey: "Daisy", secondKey: "Stella", firstName: "Daisy", secondName: "Stella", language: "en-UK", category: .enUK)
]
}

View file

@ -110,7 +110,15 @@ extension DataService {
let highlightObjects = articleProps.highlights.map {
$0.asManagedObject(context: self.backgroundContext)
}
linkedItem.addToHighlights(NSSet(array: highlightObjects))
let unsyncedHighlights = existingItem?.highlights?.filter { highlight in
if let highlight = highlight as? Highlight, highlight.serverSyncStatus == ServerSyncStatus.isNSync.rawValue {
return false
}
return true
}.compactMap { $0 as? Highlight } ?? []
linkedItem.highlights = NSSet(array: highlightObjects + unsyncedHighlights)
linkedItem.htmlContent = articleProps.htmlContent
linkedItem.id = articleProps.item.id
linkedItem.state = articleProps.item.state.rawValue

View file

@ -4,6 +4,7 @@ import Foundation
import Models
import OSLog
import QuickLookThumbnailing
import SwiftUI
import Utils
#if os(iOS)
@ -28,6 +29,10 @@ public final class DataService: ObservableObject {
persistentContainer.viewContext
}
@AppStorage(UserDefaultKey.lastItemSyncTime.rawValue) public var lastItemSyncTime = DateFormatter.formatterISO8601.string(
from: Date(timeIntervalSinceReferenceDate: 0)
)
public init(appEnvironment: AppEnvironment, networker: Networker) {
self.appEnvironment = appEnvironment
self.networker = networker
@ -96,10 +101,7 @@ public final class DataService: ObservableObject {
}
public func resetCoreData() {
UserDefaults.standard.set(
DateFormatter.formatterISO8601.string(from: Date(timeIntervalSinceReferenceDate: 0)),
forKey: UserDefaultKey.lastItemSyncTime.rawValue
)
lastItemSyncTime = DateFormatter.formatterISO8601.string(from: Date(timeIntervalSinceReferenceDate: 0))
clearCoreData()
@ -177,10 +179,9 @@ public final class DataService: ObservableObject {
linkedItem.contentReader = "PDF"
linkedItem.tempPDFURL = localUrl
linkedItem.title = PDFUtils.titleFromPdfFile(pageScrape.url)
case let .html(html: html, title: title, iconURL: iconURL):
case let .html(html: html, title: title, highlightData: _):
linkedItem.contentReader = "WEB"
linkedItem.originalHtml = html
linkedItem.imageURLString = iconURL
linkedItem.title = title ?? PDFUtils.titleFromPdfFile(pageScrape.url)
case .none:
linkedItem.contentReader = "WEB"

View file

@ -0,0 +1,57 @@
//
// File.swift
//
//
// Created by Jackson Harper on 11/10/22.
//
import Foundation
import Models
import SwiftGraphQL
public struct Feature {
public let name: String
public let token: String
public let granted: Bool
}
public extension DataService {
func optInFeature(name: String) async throws -> Feature? {
enum MutationResult {
case success(feature: Feature)
case error(errorCode: Enums.OptInFeatureErrorCode)
}
let featureSelection = Selection.Feature { Feature(name: try $0.name(), token: try $0.token(), granted: try $0.grantedAt() != nil) }
let selection = Selection<MutationResult, Unions.OptInFeatureResult> {
try $0.on(
optInFeatureError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) },
optInFeatureSuccess: .init { .success(feature: try $0.feature(selection: featureSelection)) }
)
}
let mutation = Selection.Mutation {
try $0.optInFeature(input: InputObjects.OptInFeatureInput(name: name),
selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return try await withCheckedThrowingContinuation { continuation in
send(mutation, to: path, headers: headers) { queryResult in
guard let payload = try? queryResult.get() else {
continuation.resume(throwing: BasicError.message(messageText: "network error"))
return
}
switch payload.data {
case let .success(feature: feature):
continuation.resume(returning: feature)
case let .error(errorCode: errorCode):
continuation.resume(throwing: BasicError.message(messageText: errorCode.rawValue))
}
}
}
}
}

View file

@ -5,9 +5,13 @@ import Utils
public extension DataService {
func prefetchPages(itemIDs: [String], username: String) async {
// TODO: make this concurrent
for itemID in itemIDs {
await prefetchPage(pendingLink: PendingLink(itemID: itemID, retryCount: 1), username: username)
await withTaskGroup(of: Void.self) { group in
for itemID in itemIDs {
group.addTask {
await self.prefetchPage(pendingLink: PendingLink(itemID: itemID, retryCount: 1), username: username)
}
}
await group.waitForAll()
}
}

View file

@ -14,8 +14,8 @@ public extension DataService {
func syncLinkedItems(
since date: Date,
cursor: String?,
previousQueryResult: LinkedItemQueryResult? = nil
) async throws -> LinkedItemQueryResult {
previousQueryResult: LinkedItemSyncResult? = nil
) async throws -> LinkedItemSyncResult? {
if previousQueryResult == nil {
// Send offline changes to server before fetching items
// only on the first call of this function
@ -26,16 +26,17 @@ public extension DataService {
LinkedItem.deleteItems(ids: fetchResult.deletedItemIDs, context: backgroundContext)
guard let itemIDs = fetchResult.items.persist(context: backgroundContext) else {
if fetchResult.items.persist(context: backgroundContext) == nil {
throw BasicError.message(messageText: "CoreData error")
}
let result = LinkedItemQueryResult(
itemIDs: itemIDs + (previousQueryResult?.itemIDs ?? []),
let prev = previousQueryResult?.updatedItemIDs ?? []
let result = LinkedItemSyncResult(
updatedItemIDs: prev + fetchResult.items.map(\.id),
cursor: fetchResult.cursor
)
if fetchResult.hasMoreItems, (previousQueryResult?.itemIDs.count ?? 0) < 200 {
if fetchResult.hasMoreItems, (previousQueryResult?.updatedItemIDs.count ?? 0) < 200 {
return try await syncLinkedItems(
since: date,
cursor: fetchResult.cursor,
@ -58,7 +59,7 @@ public extension DataService {
cursor: String?
) async throws -> LinkedItemQueryResult {
// Send offline changes to server before fetching items
try? await syncOfflineItemsWithServerIfNeeded()
// try? await syncOfflineItemsWithServerIfNeeded()
let fetchResult = try await fetchLinkedItems(limit: limit, searchQuery: searchQuery, cursor: cursor)

View file

@ -14,5 +14,5 @@ public enum FeatureFlag {
public static let enableShareButton = false
public static let enableSnooze = false
public static let enableGridCardsOnPhone = false
public static let enableHighlightsView = true
public static let enableUltraRealisticVoices = false
}

Some files were not shown because too many files have changed in this diff Show more