mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge branch 'main' of github.com:omnivore-app/omnivore into feat/landingpages
This commit is contained in:
commit
a9554a6d21
144 changed files with 2730 additions and 1762 deletions
4
.github/workflows/run-tests.yaml
vendored
4
.github/workflows/run-tests.yaml
vendored
|
|
@ -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 .'
|
||||
|
|
|
|||
23
android/Omnivore/app/src/main/graphql/MergeHighlight.graphql
Normal file
23
android/Omnivore/app/src/main/graphql/MergeHighlight.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
android/Omnivore/app/src/main/res/drawable-v24/close.xml
Normal file
1
android/Omnivore/app/src/main/res/drawable-v24/close.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
24
android/Omnivore/app/src/main/res/layout/annotation_edit.xml
Normal file
24
android/Omnivore/app/src/main/res/layout/annotation_edit.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Axel.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Axel.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Daisy.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Daisy.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Ellie.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Ellie.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Larry.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Larry.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Nolan.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Nolan.mp3
Normal file
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Nova.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Nova.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Owen.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Owen.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Susan.mp3
Normal file
BIN
apple/OmnivoreKit/Sources/Utils/Resources/VoiceSamples/Susan.mp3
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue