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

This commit is contained in:
Rupin Khandelwal 2022-11-16 17:58:24 -03:00
commit a9554a6d21
144 changed files with 2730 additions and 1762 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,12 +1,13 @@
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 linkedItem: LinkedItem?
@ -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
@ -72,7 +89,7 @@ public class ShareExtensionViewModel: ObservableObject {
switch payload.contentType {
case let .html(html: _, title: title, iconURL: iconURL):
self.title = title
self.title = title ?? ""
self.iconURL = iconURL
self.url = hostname
case .none:

View file

@ -6,10 +6,22 @@ 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 editingTitle = false
@State var editingLabels = false
@State var previousLabels: [LinkedItemLabel]?
@State var messageText: String?
enum FocusField: Hashable {
case titleEditor
}
@FocusState private var focusedField: FocusField?
private func handleReminderTimeSelection(_ selectedTime: ReminderTime) {
if selectedTime == reminderTime {
@ -134,61 +146,331 @@ public struct ShareExtensionView: View {
.cornerRadius(8)
}
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)
var isSynced: Bool {
switch viewModel.status {
case .synced:
return true
default:
return false
}
}
Rectangle()
.foregroundColor(.appGrayText)
.frame(maxWidth: .infinity, maxHeight: 1)
.opacity(0.06)
.padding(.top, 0)
.padding(.bottom, 18)
var titleBar: some View {
HStack {
Spacer()
previewCard
.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
Image(systemName: "checkmark.circle")
.frame(width: 15, height: 15)
.foregroundColor(.appGreenSuccess)
.opacity(isSynced ? 1.0 : 0.0)
if let item = viewModel.linkedItem {
ApplyLabelsListView(linkedItem: item)
Text(messageText ?? titleText)
.font(.appSubheadline)
.foregroundColor(isSynced ? .appGreenSuccess : .appGrayText)
Spacer()
}
}
public var titleBox: some View {
VStack(alignment: .trailing) {
Button(action: {}, label: {
Text("Edit")
.font(.appFootnote)
.padding(.trailing, 8)
.onTapGesture {
editingTitle = true
}
})
.disabled(editingTitle)
.opacity(editingTitle ? 0.0 : 1.0)
VStack(alignment: .leading) {
if !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 {}
}
.frame(maxWidth: .infinity, maxHeight: 60)
.padding()
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.appGrayBorder, lineWidth: 1)
)
}
}
var labelsSection: some View {
HStack {
if !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: "chevron.right")
.font(.appCallout)
} else {
ScrollView {
LabelsMasonaryView(labels: labelsViewModel.labels,
selectedLabels: labelsViewModel.selectedLabels,
onLabelTap: onLabelTap)
}.background(Color.appButtonBackground)
.cornerRadius(8)
}
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: self.editingLabels ? .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)
}
}
public var body: some View {
VStack(alignment: .center) {
Capsule()
.fill(.gray)
.frame(width: 60, height: 4)
.padding(.top, 10)
if !editingLabels, !editingTitle {
titleBar
.padding(.top, 10)
.padding(.bottom, 12)
} else {
ZStack {
Button(action: {
withAnimation {
if editingLabels {
if let linkedItem = self.viewModel.linkedItem {
self.labelsViewModel.selectedLabels = previousLabels ?? []
self.labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID,
dataService: self.viewModel.services.dataService)
}
}
editingTitle = false
editingLabels = false
}
}, label: { Text("Cancel") })
.frame(maxWidth: .infinity, alignment: .leading)
Text(editingTitle ? "Edit Title" : "Labels").bold()
.frame(maxWidth: .infinity, alignment: .center)
Button(action: {
withAnimation {
editingTitle = false
editingLabels = false
if 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 !editingLabels, !editingTitle {
titleBox
}
if 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)
if !editingTitle {
labelsSection
.padding(.top, 12)
.onTapGesture {
withAnimation {
previousLabels = self.labelsViewModel.selectedLabels
editingLabels = true
}
}
)
.buttonStyle(RoundedRectButtonStyle())
}
.padding(.horizontal)
.padding(.bottom)
Spacer()
if !editingLabels, !editingTitle {
Divider()
.padding(.bottom, 20)
primaryButtons
moreActionsMenu
}
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .topLeading
)
.padding(.horizontal, 16)
.onAppear {
viewModel.savePage(extensionContext: extensionContext)
}
.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 {
@ -292,59 +354,7 @@ import Views
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 +438,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

@ -175,14 +175,6 @@ import Views
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,99 @@
//
// 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 {
if item.selected {
print(" -- SELECTED LABEL", item.label.name)
}
print("GETTING ITERATION", iteration)
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

@ -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)

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

@ -62,11 +62,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

@ -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

@ -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

@ -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