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" }