diff --git a/app/build.gradle b/app/build.gradle index d20b525c499..36451601af1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -183,6 +183,7 @@ dependencies { implementation libs.material implementation libs.appcompat implementation libs.core.ktx + implementation libs.concurrent.futures implementation libs.browser implementation libs.constraintlayout implementation libs.fragment.ktx @@ -213,6 +214,10 @@ dependencies { annotationProcessor libs.androidx.room.compiler ksp libs.androidx.room.compiler implementation libs.androidx.room.ktx + implementation libs.media3.common + implementation libs.media3.exoplayer + implementation libs.media3.session + implementation libs.media3.ui // For language detection during editing prodImplementation libs.com.google.mlkit.language.id diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 31bede1cce2..da5bf84f7e5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -50,6 +50,9 @@ + + + @@ -447,5 +450,15 @@ + + + + + + diff --git a/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt b/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt index 5080e97970a..0ce4fcee0ff 100644 --- a/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt +++ b/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt @@ -205,6 +205,27 @@ object JavaScriptActionHandler { "})();" } + fun getSpokenFileName(): String { + return "(function() {" + + " let spokenDiv = document.querySelector('.spoken-wikipedia');" + + " let spokenAudio = spokenDiv.querySelector('audio');" + + " let spokenSources = spokenDiv.querySelectorAll('source');" + + " return spokenSources[0].getAttribute('src');" + + "})();" + } + + fun getSectionContents(): String { + return "(function() {" + + " let sections = document.querySelectorAll('section');" + + " let section = sections[0].cloneNode(true);" + + " let elements = section.querySelectorAll('style,.IPA,.mw-ref,.hatnote,.pcs-collapse-table-container');" + + " for (let i = 0; i < elements.length; i++) {" + + " elements[i].remove();" + + " }" + + " return section.innerText;" + + "})();" + } + @Serializable class ImageHitInfo(val left: Float = 0f, val top: Float = 0f, val width: Float = 0f, val height: Float = 0f, val src: String = "") diff --git a/app/src/main/java/org/wikipedia/page/PageActivity.kt b/app/src/main/java/org/wikipedia/page/PageActivity.kt index e42bd60b5c2..68f1d33950c 100644 --- a/app/src/main/java/org/wikipedia/page/PageActivity.kt +++ b/app/src/main/java/org/wikipedia/page/PageActivity.kt @@ -64,6 +64,7 @@ import org.wikipedia.notifications.AnonymousNotificationHelper import org.wikipedia.notifications.NotificationActivity import org.wikipedia.page.linkpreview.LinkPreviewDialog import org.wikipedia.page.tabs.TabActivity +import org.wikipedia.page.tts.Tts import org.wikipedia.readinglist.ReadingListActivity import org.wikipedia.readinglist.ReadingListMode import org.wikipedia.search.SearchActivity diff --git a/app/src/main/java/org/wikipedia/page/PageFragment.kt b/app/src/main/java/org/wikipedia/page/PageFragment.kt index fa301a97dc2..91e241f5e3d 100644 --- a/app/src/main/java/org/wikipedia/page/PageFragment.kt +++ b/app/src/main/java/org/wikipedia/page/PageFragment.kt @@ -5,6 +5,7 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.content.res.Configuration +import android.icu.text.BreakIterator import android.net.Uri import android.os.Bundle import android.view.ActionMode @@ -25,6 +26,7 @@ import androidx.core.animation.doOnEnd import androidx.core.app.ActivityCompat import androidx.core.app.ActivityOptionsCompat import androidx.core.view.forEach +import androidx.core.view.isVisible import androidx.core.widget.TextViewCompat import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope @@ -91,6 +93,9 @@ import org.wikipedia.page.references.PageReferences import org.wikipedia.page.references.ReferenceDialog import org.wikipedia.page.shareafact.ShareHandler import org.wikipedia.page.tabs.Tab +import org.wikipedia.page.tts.NarrationPopupView +import org.wikipedia.page.tts.PlaybackService +import org.wikipedia.page.tts.Tts import org.wikipedia.places.PlacesActivity import org.wikipedia.readinglist.LongPressMenu import org.wikipedia.readinglist.ReadingListBehaviorsUtil @@ -105,6 +110,7 @@ import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.ImageUrlUtil import org.wikipedia.util.ResourceUtil import org.wikipedia.util.ShareUtil +import org.wikipedia.util.StringUtil import org.wikipedia.util.ThrowableUtil import org.wikipedia.util.UriUtil import org.wikipedia.util.log.L @@ -117,6 +123,7 @@ import org.wikipedia.watchlist.WatchlistViewModel import org.wikipedia.wiktionary.WiktionaryDialog import java.time.Duration import java.time.Instant +import java.util.Locale class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.CommunicationBridgeListener, ThemeChooserDialog.Callback, ReferenceDialog.Callback, WiktionaryDialog.Callback, WatchlistExpiryDialog.Callback { @@ -193,6 +200,29 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi val isLoading get() = bridge.isLoading val leadImageEditLang get() = leadImagesHandler.callToActionEditLang + val checkTtsServiceRunnable = TtsCheckRunnable() + + inner class TtsCheckRunnable() : Runnable { + override fun run() { + if (!isAdded) { + return + } + + if (PlaybackService.isRunning) { + if (!binding.speechButton.isVisible) { + binding.speechButton.show() + } + } else { + if (binding.speechButton.isVisible) { + binding.speechButton.hide() + } + } + + binding.speechButton.postDelayed(checkTtsServiceRunnable, 1000) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentPageBinding.inflate(inflater, container, false) webView = binding.pageWebView @@ -228,6 +258,12 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi } } + binding.speechButton.setOnClickListener { + Tts.currentPageTitle?.let { title -> + NarrationPopupView(requireActivity()).show(binding.speechButton, title) + } + } + bottomBarHideHandler = ViewHideHandler(binding.pageActionsTabContainer, null, Gravity.BOTTOM, updateElevation = false) { false } bottomBarHideHandler.setScrollView(webView) bottomBarHideHandler.enabled = Prefs.readingFocusModeEnabled @@ -253,6 +289,8 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi if (shouldLoadFromBackstack(activity) || savedInstanceState != null) { reloadFromBackstack() } + + binding.speechButton.postDelayed(checkTtsServiceRunnable, 1000) } override fun onSaveInstanceState(outState: Bundle) { @@ -1477,6 +1515,67 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi articleInteractionEvent?.logForwardClick() metricsPlatformArticleEventToolbarInteraction.logForwardClick() } + + override fun onNarrateSelected() { + bridge.evaluate(JavaScriptActionHandler.getSpokenFileName()) { result -> + var spokenUrl = if (result == null || result == "null") "" else result + if (spokenUrl.length > 2 && spokenUrl.startsWith("\"") && spokenUrl.endsWith("\"")) { + spokenUrl = spokenUrl.substring(1, spokenUrl.length - 1) + } + if (spokenUrl.isNotEmpty()) { + speakFromUrl(UriUtil.resolveProtocolRelativeUrl(spokenUrl)) + } else { + speakFromTts() + } + } + } + + private fun speakFromUrl(url: String) { + Tts.start(requireActivity(), title, url, listOf()) + } + + private fun speakFromTts() { + bridge.evaluate(JavaScriptActionHandler.getSectionContents()) { result -> + var text = if (result == null || result == "null") "" else result + if (text.length > 2 && text.startsWith("\"") && text.endsWith("\"")) { + text = text.substring(1, text.length - 1) + } + + // massage the text a bit further + text = text.replace("\\n", "\n").replace("\\\"", "\"").replace("\\'", "'") + + text = StringUtil.fromHtml(title?.displayText) + .toString() + "\n\n" + title?.description + "\n\n" + text + + + val sentences = mutableListOf() + var sentence = "" + + val iterator = BreakIterator.getSentenceInstance(Locale.getDefault()) + iterator.setText(text) + var start: Int = iterator.first() + if (start != BreakIterator.DONE) { + var end: Int = iterator.next() + while (end != BreakIterator.DONE) { + val chunk = text.substring(start, end) + sentence += "$chunk " + // sentences should be at least 32 characters long. + // TODO: limit size of sentences to 1024 characters? + if (sentence.length + chunk.length > 32) { + sentences.add(sentence) + sentence = "" + } + start = end + end = iterator.next() + } + } + if (sentence.isNotEmpty()) { + sentences.add(sentence) + } + + Tts.start(requireActivity(), title, "", sentences) + } + } } companion object { diff --git a/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt b/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt index 7346d006011..cecb6c048b3 100644 --- a/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt +++ b/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt @@ -82,6 +82,11 @@ enum class PageActionItem constructor(val id: Int, override fun select(cb: Callback) { cb.onViewOnMapSelected() } + }, + NARRATE(14, R.id.page_narrate, R.string.action_item_narrate, R.drawable.ic_volume_up, false) { + override fun select(cb: Callback) { + cb.onNarrateSelected() + } }; abstract fun select(cb: Callback) @@ -108,6 +113,7 @@ enum class PageActionItem constructor(val id: Int, fun onEditArticleSelected() fun onViewOnMapSelected() fun forwardClick() + fun onNarrateSelected() } companion object { diff --git a/app/src/main/java/org/wikipedia/page/tts/NarrationPopupView.kt b/app/src/main/java/org/wikipedia/page/tts/NarrationPopupView.kt new file mode 100644 index 00000000000..fe2e9568185 --- /dev/null +++ b/app/src/main/java/org/wikipedia/page/tts/NarrationPopupView.kt @@ -0,0 +1,113 @@ +package org.wikipedia.page.tts + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.PopupWindow +import androidx.core.view.doOnDetach +import androidx.core.view.isVisible +import androidx.core.widget.PopupWindowCompat +import androidx.media3.session.SessionCommand +import org.wikipedia.R +import org.wikipedia.databinding.ViewNarrationPopupBinding +import org.wikipedia.page.PageTitle +import org.wikipedia.util.DimenUtil +import org.wikipedia.util.StringUtil +import org.wikipedia.views.ViewUtil +import androidx.core.graphics.drawable.toDrawable + +class NarrationPopupView(context: Context) : FrameLayout(context) { + + private var binding = ViewNarrationPopupBinding.inflate(LayoutInflater.from(context), this, true) + private var popupWindowHost: PopupWindow? = null + + fun show(anchorView: View, pageTitle: PageTitle) { + popupWindowHost = PopupWindow(this, ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, true) + popupWindowHost?.let { + it.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) + PopupWindowCompat.setOverlapAnchor(it, true) + it.showAsDropDown(anchorView, 0, anchorView.height - DimenUtil.roundedDpToPx(48f*4), Gravity.END or Gravity.BOTTOM) + } + + anchorView.doOnDetach { + dismissPopupWindowHost() + } + + binding.articleTitle.text = StringUtil.fromHtml(pageTitle.displayText) + if (pageTitle.thumbUrl.isNullOrEmpty()) { + binding.articleThumbnail.isVisible = false + } else { + binding.articleThumbnail.isVisible = true + ViewUtil.loadImage(binding.articleThumbnail, pageTitle.thumbUrl) + } + updateSpeedButtons() + updatePlayPauseButton() + + binding.seekBackButton.setOnClickListener { + Tts.mediaController?.sendCustomCommand(SessionCommand(PlaybackService.CUSTOM_COMMAND_REWIND_SEC, Bundle()), Bundle()) + updatePlayPauseButton() + } + + binding.seekForwardButton.setOnClickListener { + Tts.mediaController?.sendCustomCommand(SessionCommand(PlaybackService.CUSTOM_COMMAND_FORWARD_SEC, Bundle()), Bundle()) + updatePlayPauseButton() + } + + binding.playPauseButton.setOnClickListener { + Tts.mediaController?.let { + if (it.isPlaying) { + it.pause() + } else { + it.play() + } + } + updatePlayPauseButton() + } + + binding.decreaseSpeedButton.setOnClickListener { + PlaybackService.speechRate -= 0.1f + Tts.mediaController?.setPlaybackSpeed(PlaybackService.speechRate) + updateSpeedButtons() + } + + binding.increaseSpeedButton.setOnClickListener { + PlaybackService.speechRate += 0.1f + Tts.mediaController?.setPlaybackSpeed(PlaybackService.speechRate) + updateSpeedButtons() + } + + binding.stopButton.setOnClickListener { + + context.stopService(Intent(context, PlaybackService::class.java)) + Tts.cleanup() + + dismissPopupWindowHost() + } + } + + private fun dismissPopupWindowHost() { + popupWindowHost?.let { + it.dismiss() + popupWindowHost = null + } + } + + private fun updatePlayPauseButton() { + Tts.mediaController?.let { + binding.playPauseButton.setImageResource(if (it.isPlaying) R.drawable.ic_pause_black_24dp else R.drawable.ic_play_arrow_black_24dp) + } + } + + private fun updateSpeedButtons() { + binding.decreaseSpeedButton.isEnabled = PlaybackService.speechRate > 0.1f + binding.increaseSpeedButton.isEnabled = PlaybackService.speechRate < 2.0f + binding.speedText.text = String.format("%.1fx", PlaybackService.speechRate) + } +} diff --git a/app/src/main/java/org/wikipedia/page/tts/Tts.kt b/app/src/main/java/org/wikipedia/page/tts/Tts.kt new file mode 100644 index 00000000000..b6dd851a41a --- /dev/null +++ b/app/src/main/java/org/wikipedia/page/tts/Tts.kt @@ -0,0 +1,94 @@ +package org.wikipedia.page.tts + +import android.content.ComponentName +import android.content.Context +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.google.common.util.concurrent.MoreExecutors +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.page.PageTitle +import org.wikipedia.util.StringUtil +import androidx.core.net.toUri + +object Tts { + + var utterances: List = emptyList() + var currentUtterance = 0 + + var audioUrl: String = "" + + + var currentPageTitle: PageTitle? = null + set(value) { + if (value == null) { + field = null + } else { + val title = PageTitle(value.prefixedText, value.wikiSite) + title.description = value.description + title.thumbUrl = value.thumbUrl + field = title + } + } + + var mediaController: MediaController? = null + + fun cleanup() { + mediaController?.stop() + mediaController?.release() + mediaController = null + } + + fun start(context: Context, pageTitle: PageTitle?, audioUrl: String, utterances: List) { + currentPageTitle = pageTitle + this.utterances = utterances + currentUtterance = 0 + + this.audioUrl = audioUrl + + //if (mediaController?.isConnected == false) { + cleanup() + //} + + if (mediaController == null) { + val sessionToken = SessionToken(context, ComponentName(context, PlaybackService::class.java)) + val controllerFuture = MediaController.Builder(context, sessionToken).buildAsync() + controllerFuture.addListener( + { + mediaController = controllerFuture.get() + // playerView.setPlayer(controller) + speak(context, audioUrl) + }, + MoreExecutors.directExecutor() + ) + } else { + speak(context, audioUrl) + } + } + + private fun speak(context: Context, audioUrl: String) { + + val mediaItemBuilder = MediaItem.Builder() + .setMediaId("media-1") + .setMediaMetadata(MediaMetadata.Builder() + .setArtist(context.getString(R.string.app_name)) + .setTitle(StringUtil.fromHtml(currentPageTitle?.displayText.orEmpty())) + .setArtworkUri(currentPageTitle?.thumbUrl.orEmpty().toUri()) + .build() + ) + + if (audioUrl.isNotBlank()) { + mediaItemBuilder.setUri(audioUrl.toUri()) + } else { + mediaItemBuilder.setUri(currentPageTitle!!.uri.toUri()) + } + + WikipediaApp.instance.mainThreadHandler.post { + mediaController?.setMediaItem(mediaItemBuilder.build()) + mediaController?.prepare() + mediaController?.play() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/wikipedia/page/tts/TtsService.kt b/app/src/main/java/org/wikipedia/page/tts/TtsService.kt new file mode 100644 index 00000000000..a0c1ae5db2e --- /dev/null +++ b/app/src/main/java/org/wikipedia/page/tts/TtsService.kt @@ -0,0 +1,454 @@ +package org.wikipedia.page.tts + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.tts.TextToSpeech +import android.speech.tts.TextToSpeech.OnInitListener +import android.speech.tts.UtteranceProgressListener +import androidx.annotation.OptIn +import androidx.concurrent.futures.CallbackToFutureAdapter +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.Player.Commands +import androidx.media3.common.SimpleBasePlayer +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.CommandButton +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.ConnectionResult +import androidx.media3.session.MediaSessionService +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.util.log.L +import java.util.Locale +import kotlin.math.max + +class PlaybackService : MediaSessionService() { + private var currentSession: MediaSession? = null + + var textToSpeech: TextToSpeech? = null + + private val customLayoutCommandButtons: List = + listOf( + CommandButton.Builder() + .setDisplayName("Rewind") + .setEnabled(true) + .setIconResId(R.drawable.ic_replay_10) + .setSessionCommand(SessionCommand(CUSTOM_COMMAND_REWIND_SEC, Bundle.EMPTY)) + .build(), + CommandButton.Builder() + .setDisplayName("Forward") + .setEnabled(true) + .setIconResId(R.drawable.ic_forward_10) + .setSessionCommand(SessionCommand(CUSTOM_COMMAND_FORWARD_SEC, Bundle.EMPTY)) + .build(), + ) + + @OptIn(UnstableApi::class) + override fun onCreate() { + L.d(">>>> PlaybackService onCreate") + super.onCreate() + + createSession() + } + + @OptIn(UnstableApi::class) + private fun createSession() { + currentSession = MediaSession.Builder(this, createPlayer()) + .setCallback(object : MediaSession.Callback { + + override fun onConnect( + session: MediaSession, + controller: MediaSession.ControllerInfo + ): ConnectionResult { + L.d(">>>> MediaSession onConnect") + isRunning = true + + // Recreate the player upon connection to a new controller. + // (It needs to be a different type of player based on TTS vs audio URL) + // TODO: is this right? + currentSession?.player?.stop() + currentSession?.player?.release() + currentSession?.player = createPlayer() + + if (session.isMediaNotificationController(controller)) { + val sessionCommands = + ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() + .also { builder -> + customLayoutCommandButtons.forEach { commandButton -> + commandButton.sessionCommand?.let { builder.add(it) } + } + } + .build() + val playerCommands = + ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon() + //.remove(COMMAND_SEEK_TO_PREVIOUS) + //.remove(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + //.remove(COMMAND_SEEK_TO_NEXT) + //.remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .build() + + // Custom layout and available commands to configure the legacy/framework session. + return ConnectionResult.AcceptedResultBuilder(session) + .setCustomLayout(customLayoutCommandButtons) + .setAvailablePlayerCommands(playerCommands) + .setAvailableSessionCommands(sessionCommands) + .build() + } else { + // Default commands with default custom layout for all other controllers. + + val sessionCommands = ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon() + .also { builder -> + customLayoutCommandButtons.forEach { commandButton -> + commandButton.sessionCommand?.let { builder.add(it) } + } + }.build() + val playerCommands = ConnectionResult.DEFAULT_PLAYER_COMMANDS + + return ConnectionResult.AcceptedResultBuilder(session) + .setAvailablePlayerCommands(playerCommands) + .setAvailableSessionCommands(sessionCommands) + .build() + } + } + + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + L.d(">>>> MediaSession onCustomCommand: ${customCommand.customAction}") + if (customCommand.customAction == CUSTOM_COMMAND_REWIND_SEC) { + if (session.player is TtsPlayer) { + (session.player as TtsPlayer).speakPrevUtterance() + } else { + session.player.seekTo(session.player.currentPosition - 10_000) + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } else if (customCommand.customAction == CUSTOM_COMMAND_FORWARD_SEC) { + if (session.player is TtsPlayer) { + (session.player as TtsPlayer).speakNextUtterance() + } else { + session.player.seekTo(session.player.currentPosition + 10_000) + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + return super.onCustomCommand(session, controller, customCommand, args) + } + + override fun onDisconnected( + session: MediaSession, + controller: MediaSession.ControllerInfo + ) { + L.d(">>>> MediaSession onDisconnected") + isRunning = false + super.onDisconnected(session, controller) + } + + override fun onPlaybackResumption( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo + ): ListenableFuture { + L.d(">>>> MediaSession onPlaybackResumption") + // TODO: Implement playback resumption, i.e. restore the playback state after + // the session was disconnected and reconnected. + return super.onPlaybackResumption(mediaSession, controller) + } + }) + .build() + } + + override fun onDestroy() { + L.d(">>>> PlaybackService onDestroy") + currentSession?.run { + player.stop() + player.release() + release() + currentSession = null + } + isRunning = false + super.onDestroy() + } + + override fun onTaskRemoved(rootIntent: Intent?) { + L.d(">>>> PlaybackService onTaskRemoved") + val player = currentSession?.player + if (player != null && (!player.playWhenReady + || player.mediaItemCount == 0 + || player.playbackState == Player.STATE_ENDED)) { + // Stop the service if not playing, continue playing in the background + // otherwise. + stopSelf() + } + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { + L.d(">>>> PlaybackService onGetSession") + return currentSession + } + + private fun createPlayer(): Player { + return if (Tts.audioUrl.isEmpty()) TtsPlayer(Looper.getMainLooper()) + else ExoPlayer.Builder(this@PlaybackService).build() + } + + companion object { + var isRunning = false + + var speechRate = 1f + + const val CUSTOM_COMMAND_REWIND_SEC = "CUSTOM_COMMAND_REWIND_SEC" + const val CUSTOM_COMMAND_FORWARD_SEC = "CUSTOM_COMMAND_FORWARD_SEC" + } + + + + @OptIn(UnstableApi::class) + inner class TtsPlayer(looper: Looper) : SimpleBasePlayer(looper), OnInitListener { + + private var state = State.Builder() + .setAvailableCommands(Commands.Builder().addAllCommands() + .remove(COMMAND_SEEK_TO_NEXT) + .remove(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + .build()) + //.setPlayWhenReady(true, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .setPlaybackState(STATE_IDLE) + //.setAudioAttributes(AudioAttributes.DEFAULT) + //.setPlaylist(listOf(MediaItemData.Builder("test").build())) + //.setPlaylistMetadata( + // MediaMetadata.Builder().setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) + //// .setTitle("TTS test") + // .build() + //) + .build() + + private val utteranceListener = object : + UtteranceProgressListener() { + override fun onStart(utteranceId: String) { + L.d(">>>> TTS onStart") + } + + override fun onDone(utteranceId: String) { + L.d(">>>> TTS onDone") + speakNextUtterance() + } + + override fun onError(utteranceId: String) { + L.d(">>>> TTS onError") + updatePlaybackState(STATE_ENDED) + } + + override fun onError(utteranceId: String?, errorCode: Int) { + L.d(">>>> TTS onError: $errorCode") + updatePlaybackState(STATE_ENDED) + } + } + + override fun getState(): State { + return state + } + + override fun handleAddMediaItems(index: Int, mediaItems: MutableList): ListenableFuture<*> { + L.d(">>>> handleAddMediaItems") + return Futures.immediateVoidFuture() + } + + override fun handleSetMediaItems(mediaItems: MutableList, startIndex: Int, startPositionMs: Long): ListenableFuture<*> { + L.d(">>>> handleSetMediaItems") + Handler(Looper.getMainLooper()).post { + + val totalDuration = max(Tts.utterances.size * 10_000L, 10_000L) + + state = state.buildUpon() + .setPlaylist(listOf(MediaItemData + .Builder("test") + .setMediaItem(mediaItems[0]) + .setIsSeekable(true) + .setDurationUs(totalDuration * 1000) + .build())) + .setIsLoading(false) + .setContentPositionMs(0) + .build() + invalidateState() + } + return Futures.immediateVoidFuture() + } + + override fun handleSetPlaylistMetadata(playlistMetadata: MediaMetadata): ListenableFuture<*> { + L.d(">>>> handleSetPlaylistMetadata") + return Futures.immediateVoidFuture() + } + + override fun handleSetPlaybackParameters(playbackParameters: PlaybackParameters): ListenableFuture<*> { + L.d(">>>> handleSetPlaybackParameters") + + textToSpeech?.stop() + textToSpeech?.setSpeechRate(playbackParameters.speed) + if (playWhenReady) { + speakCurrentUtterance() + } + + return Futures.immediateVoidFuture() + } + + override fun handlePrepare(): ListenableFuture<*> { + L.d(">>>> handlePrepare") + + if (playWhenReady) { + if (textToSpeech != null) { + speakCurrentUtterance() + } + updatePlaybackState(STATE_READY, true) + } else { + textToSpeech?.stop() + updatePlaybackState(STATE_READY, false) + } + + if (textToSpeech != null) { + return Futures.immediateVoidFuture() + } + + return CallbackToFutureAdapter.getFuture { completer -> + textToSpeech = TextToSpeech(WikipediaApp.instance) { status -> + if (status == TextToSpeech.SUCCESS) { + textToSpeech?.setOnUtteranceProgressListener(utteranceListener) + textToSpeech?.setLanguage(Locale.getDefault()) + textToSpeech?.setSpeechRate(speechRate) + + if (playWhenReady) { + speakCurrentUtterance() + updatePlaybackState(STATE_READY, true) + } + + } else { + L.d(">>>> Failed to initialize TTS") + textToSpeech = null + } + completer.set(Unit) + } + Unit + } + } + + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + L.d(">>>> handleSetPlayWhenReady: $playWhenReady") + + if (playWhenReady) { + if (textToSpeech?.isSpeaking == false) { + speakCurrentUtterance() + } + updatePlaybackState(STATE_READY, true) + } else { + if (textToSpeech?.isSpeaking == true) { + textToSpeech?.stop() + } + updatePlaybackState(STATE_READY, false) + } + + return Futures.immediateVoidFuture() + } + + override fun handleRelease(): ListenableFuture<*> { + L.d(">>>> handleRelease") + textToSpeech?.stop() + return Futures.immediateVoidFuture() + } + + override fun handleStop(): ListenableFuture<*> { + L.d(">>>> handleStop") + textToSpeech?.stop() + return Futures.immediateVoidFuture() + } + + override fun handleSeek(mediaItemIndex: Int, positionMs: Long, seekCommand: Int): ListenableFuture<*> { + L.d(">>>> handleSeek: $positionMs") + + if (seekCommand == COMMAND_SEEK_TO_DEFAULT_POSITION) { + Tts.currentUtterance = 0 + updatePlaybackPosition(0) + if (playWhenReady) { + speakCurrentUtterance() + } + } else if (seekCommand == COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) { + Tts.currentUtterance = (positionMs / 10_000).toInt() + updatePlaybackPosition(Tts.currentUtterance * 10_000L) + if (playWhenReady) { + speakCurrentUtterance() + } + } + + return Futures.immediateVoidFuture() + } + + override fun handleSetShuffleModeEnabled(shuffleModeEnabled: Boolean): ListenableFuture<*> { + L.d(">>>> handleSetShuffleModeEnabled: $shuffleModeEnabled") + return Futures.immediateVoidFuture() + } + + override fun onInit(status: Int) { + L.d(">>>> onInit: $status") + } + + private fun updatePlaybackState(playbackState: Int, playWhenReady: Boolean = true) { + L.d(">>>> updatePlaybackState: $playbackState") + + val mainHandler = Handler(Looper.getMainLooper()) + mainHandler.post { + state = state.buildUpon() + .setPlaybackState(playbackState) + .setPlayWhenReady(playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) + .build() + invalidateState() + } + } + + private fun updatePlaybackPosition(positionMs: Long) { + L.d(">>>> updatePlaybackPosition: $positionMs") + + val mainHandler = Handler(Looper.getMainLooper()) + mainHandler.post { + state = state.buildUpon() + .setContentPositionMs(positionMs) + .build() + invalidateState() + } + } + + fun speakPrevUtterance() { + Tts.currentUtterance-- + if (Tts.currentUtterance < 0) { + Tts.currentUtterance = 0 + } + speakCurrentUtterance() + } + + fun speakNextUtterance() { + Tts.currentUtterance++ + speakCurrentUtterance() + } + + private fun speakCurrentUtterance() { + val text = Tts.utterances.getOrNull(Tts.currentUtterance).orEmpty() + if (text.isEmpty()) { + updatePlaybackState(STATE_ENDED) + Tts.currentUtterance = 0 + return + } + + updatePlaybackPosition(Tts.currentUtterance * 10_000L) + + textToSpeech?.stop() + textToSpeech?.speak(text, TextToSpeech.QUEUE_FLUSH, null, TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID) + } + + } +} diff --git a/app/src/main/java/org/wikipedia/util/ShareUtil.kt b/app/src/main/java/org/wikipedia/util/ShareUtil.kt index 202889d4e0d..ba9e855f3e3 100644 --- a/app/src/main/java/org/wikipedia/util/ShareUtil.kt +++ b/app/src/main/java/org/wikipedia/util/ShareUtil.kt @@ -91,7 +91,6 @@ object ShareUtil { private fun processBitmapForSharing(context: Context, bmp: Bitmap, imageFileName: String): File? { val shareFolder = getClearShareFolder(context) ?: return null - shareFolder.mkdirs() val bytes = FileUtil.compressBmpToJpg(bmp) return FileUtil.writeToFile(bytes, File(shareFolder, cleanFileName(imageFileName))) } @@ -120,10 +119,11 @@ object ShareUtil { Toast.makeText(context, R.string.error_can_not_process_link, Toast.LENGTH_LONG).show() } - private fun getClearShareFolder(context: Context): File? { + fun getClearShareFolder(context: Context): File? { return try { - File(getShareFolder(context), "share").also { + File(getCacheFolder(context), "share").also { it.deleteRecursively() + it.mkdirs() } } catch (caught: Throwable) { L.e("Caught " + caught.message, caught) @@ -131,7 +131,7 @@ object ShareUtil { } } - private fun getShareFolder(context: Context): File { + private fun getCacheFolder(context: Context): File { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) context.cacheDir else context.getExternalFilesDir(null)!! } diff --git a/app/src/main/res/drawable/graphic_eq_24px.xml b/app/src/main/res/drawable/graphic_eq_24px.xml new file mode 100644 index 00000000000..ee498e8ca9a --- /dev/null +++ b/app/src/main/res/drawable/graphic_eq_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_circle_24.xml b/app/src/main/res/drawable/ic_add_circle_24.xml new file mode 100644 index 00000000000..d448b37b05e --- /dev/null +++ b/app/src/main/res/drawable/ic_add_circle_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_forward_10.xml b/app/src/main/res/drawable/ic_forward_10.xml new file mode 100644 index 00000000000..2e41fb879d4 --- /dev/null +++ b/app/src/main/res/drawable/ic_forward_10.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_replay_10.xml b/app/src/main/res/drawable/ic_replay_10.xml new file mode 100644 index 00000000000..c62efc0ca63 --- /dev/null +++ b/app/src/main/res/drawable/ic_replay_10.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_volume_up.xml b/app/src/main/res/drawable/ic_volume_up.xml new file mode 100644 index 00000000000..a141ada0289 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/remove_circle_24px.xml b/app/src/main/res/drawable/remove_circle_24px.xml new file mode 100644 index 00000000000..3d31aa17cfd --- /dev/null +++ b/app/src/main/res/drawable/remove_circle_24px.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_page.xml b/app/src/main/res/layout/fragment_page.xml index 8274f87bd7e..847d6d4624a 100644 --- a/app/src/main/res/layout/fragment_page.xml +++ b/app/src/main/res/layout/fragment_page.xml @@ -1,6 +1,8 @@ @@ -41,9 +43,28 @@ + android:layout_marginBottom="@dimen/nav_bar_height"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 01adcc1ccb4..0e2fe812f31 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -11,6 +11,7 @@ + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 427d9de789c..c8cf1451fdc 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -35,8 +35,10 @@ #00AF89 #14866D + #FEE7E6 #FF4242 #B32424 + #421211 #FFCC33 #66FFCC33 diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index f126b8717a3..dd5a0f46946 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -20,4 +20,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 67e62ada8a2..ce4eedc47c3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1583,6 +1583,7 @@ Filter contributions View on map Geographical coordinates are not available for this page + Listen to article Import shared reading list diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index ec33c97b9ea..6cc30103ba0 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -79,6 +79,7 @@ @color/blue600 @color/green700 @color/red700 + @color/red100 @color/yellow700 @color/yellow500 @color/orange500 diff --git a/app/src/main/res/values/styles_dark.xml b/app/src/main/res/values/styles_dark.xml index f6fc9deb2f7..d4b785b84a6 100644 --- a/app/src/main/res/values/styles_dark.xml +++ b/app/src/main/res/values/styles_dark.xml @@ -16,6 +16,7 @@ @color/blue300 @color/green600 @color/red500 + @color/red800 @color/orange500 @color/yellow500_40 @color/orange500_50 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a641d3d5b04..b0647a86f50 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ balloon = "1.6.12" browser = "1.8.0" coilCompose = "3.3.0" commonsLang3 = "3.18.0" +concurrent = "1.2.0" constraintlayout = "2.2.1" coreKtx = "1.16.0" desugar_jdk_libs = "2.1.5" @@ -28,6 +29,7 @@ kotlinxSerializationJson = "1.9.0" kspPlugin = "2.2.0-2.0.2" leakCanaryVersion = "2.14" material = "1.12.0" +media3 = "1.5.1" metricsVersion = "2.9" mlKitVersion = "17.0.6" mockitoVersion = "5.2.0" @@ -72,6 +74,7 @@ com-google-android-gms-play-services-wallet2 = { module = "com.google.android.gm com-google-firebase-firebase-messaging-ktx3 = { module = "com.google.firebase:firebase-messaging-ktx", version.ref = "firebaseMessagingVersion" } com-google-mlkit-language-id = { module = "com.google.mlkit:language-id", version.ref = "mlKitVersion" } commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "commonsLang3" } +concurrent-futures = { module = "androidx.concurrent:concurrent-futures", version.ref = "concurrent" } constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -96,6 +99,10 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakCanaryVersion" } material = { module = "com.google.android.material:material", version.ref = "material" } +media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" } +media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +media3-session = { module = "androidx.media3:media3-session", version.ref = "media3" } +media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } metrics-platform = { module = "org.wikimedia.metrics:metrics-platform", version.ref = "metricsVersion" } mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoVersion" } mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okHttpVersion" }