@@ -0,0 +1,924 @@
package com.example.reader_app.tts
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.Parcelable
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.media.app.NotificationCompat.MediaStyle
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import com.example.reader_app.R
import kotlinx.parcelize.Parcelize
import java.util.Locale
@Parcelize
data class ReaderTtsSegment (
val text : String ,
val paragraphIndex : Int ,
val start : Int ,
val end : Int ,
) : Parcelable
class ReaderTtsMediaService : Service ( ) , TextToSpeech . OnInitListener {
companion object {
private const val NOTIFICATION _ID = 46021
private const val CHANNEL _ID = " reader_tts_playback "
private const val CHANNEL _NAME = " Reader TTS "
private const val BASE _SPEED = 0.9
private const val TAG = " ReaderTtsMediaService "
private const val HEALTH _CHECK _INTERVAL _MS = 1500L
const val ACTION _INIT = " com.example.reader_app.tts.INIT "
const val ACTION _START _READING = " com.example.reader_app.tts.START_READING "
const val ACTION _PAUSE = " com.example.reader_app.tts.PAUSE "
const val ACTION _RESUME = " com.example.reader_app.tts.RESUME "
const val ACTION _STOP = " com.example.reader_app.tts.STOP "
const val ACTION _SKIP _FORWARD = " com.example.reader_app.tts.SKIP_FORWARD "
const val ACTION _SKIP _BACK = " com.example.reader_app.tts.SKIP_BACK "
const val ACTION _SET _SPEED = " com.example.reader_app.tts.SET_SPEED "
const val ACTION _SET _VOICE = " com.example.reader_app.tts.SET_VOICE "
const val ACTION _SET _BACKGROUND _MODE = " com.example.reader_app.tts.SET_BACKGROUND_MODE "
const val EXTRA _SEGMENTS = " segments "
const val EXTRA _START _INDEX = " startIndex "
const val EXTRA _CONTENT _KEY = " contentKey "
const val EXTRA _TITLE = " title "
const val EXTRA _SPEED = " speed "
const val EXTRA _LANGUAGE = " language "
const val EXTRA _VOICE _NAME = " voiceName "
const val EXTRA _BACKGROUND _MODE _ENABLED = " backgroundModeEnabled "
const val EXTRA _CLEAR _CONTENT _KEY = " clearContentKey "
fun initialize ( context : Context , backgroundModeEnabled : Boolean ) {
context . startService (
Intent ( context , ReaderTtsMediaService :: class . java ) . apply {
action = ACTION _INIT
putExtra ( EXTRA _BACKGROUND _MODE _ENABLED , backgroundModeEnabled )
} ,
)
}
fun startReading (
context : Context ,
segments : ArrayList < ReaderTtsSegment > ,
startIndex : Int ,
contentKey : String ? ,
title : String ? ,
speed : Double ,
language : String ,
voiceName : String ? ,
backgroundModeEnabled : Boolean ,
) {
ContextCompat . startForegroundService (
context ,
Intent ( context , ReaderTtsMediaService :: class . java ) . apply {
action = ACTION _START _READING
putParcelableArrayListExtra ( EXTRA _SEGMENTS , segments )
putExtra ( EXTRA _START _INDEX , startIndex )
putExtra ( EXTRA _CONTENT _KEY , contentKey )
putExtra ( EXTRA _TITLE , title )
putExtra ( EXTRA _SPEED , speed )
putExtra ( EXTRA _LANGUAGE , language )
putExtra ( EXTRA _VOICE _NAME , voiceName )
putExtra ( EXTRA _BACKGROUND _MODE _ENABLED , backgroundModeEnabled )
} ,
)
}
fun pause ( context : Context ) =
context . startService ( Intent ( context , ReaderTtsMediaService :: class . java ) . apply {
action = ACTION _PAUSE
} )
fun resume ( context : Context ) =
context . startService ( Intent ( context , ReaderTtsMediaService :: class . java ) . apply {
action = ACTION _RESUME
} )
fun stop ( context : Context , clearContentKey : Boolean = true ) =
context . startService ( Intent ( context , ReaderTtsMediaService :: class . java ) . apply {
action = ACTION _STOP
putExtra ( EXTRA _CLEAR _CONTENT _KEY , clearContentKey )
} )
fun skipForward ( context : Context ) =
context . startService ( Intent ( context , ReaderTtsMediaService :: class . java ) . apply {
action = ACTION _SKIP _FORWARD
} )
fun skipBack ( context : Context ) =
context . startService ( Intent ( context , ReaderTtsMediaService :: class . java ) . apply {
action = ACTION _SKIP _BACK
} )
fun setSpeed ( context : Context , speed : Double ) =
context . startService ( Intent ( context , ReaderTtsMediaService :: class . java ) . apply {
action = ACTION _SET _SPEED
putExtra ( EXTRA _SPEED , speed )
} )
fun setVoice ( context : Context , voiceName : String ? , language : String ? ) =
context . startService ( Intent ( context , ReaderTtsMediaService :: class . java ) . apply {
action = ACTION _SET _VOICE
putExtra ( EXTRA _VOICE _NAME , voiceName )
putExtra ( EXTRA _LANGUAGE , language )
} )
fun setBackgroundModeEnabled ( context : Context , enabled : Boolean ) =
context . startService ( Intent ( context , ReaderTtsMediaService :: class . java ) . apply {
action = ACTION _SET _BACKGROUND _MODE
putExtra ( EXTRA _BACKGROUND _MODE _ENABLED , enabled )
} )
}
private val mainHandler = Handler ( Looper . getMainLooper ( ) )
private lateinit var notificationManager : NotificationManagerCompat
private lateinit var mediaSession : MediaSessionCompat
private lateinit var audioManager : AudioManager
private var audioFocusRequest : AudioFocusRequest ? = null
private var tts : TextToSpeech ? = null
private var isTtsReady = false
private var isForegroundActive = false
private var status = " idle "
private var speed = 0.9
private var language = " vi-VN "
private var voiceName : String ? = null
private var contentKey : String ? = null
private var title : String ? = null
private var segments : List < ReaderTtsSegment > = emptyList ( )
private var currentIndex = 0
private var completedCount = 0
private var backgroundModeEnabled = true
private var availableVoices : List < Map < String , String > > = emptyList ( )
private var sessionGeneration = 0
private var lastStartedUtterance : String ? = null
private var currentUtteranceId : String ? = null
private var currentUtteranceStarted = false
private var pendingReplayAfterInit = false
private var currentSegmentRetry = 0
private var consecutiveSilentHealthChecks = 0
private var utteranceWatchdog : Runnable ? = null
private var pausedByAudioFocus = false
private var lastSpeakRequestTimeMs = 0L
private val playbackHealthRunnable = object : Runnable {
override fun run ( ) {
runPlaybackHealthCheck ( )
mainHandler . postDelayed ( this , HEALTH _CHECK _INTERVAL _MS )
}
}
private val audioFocusListener = AudioManager . OnAudioFocusChangeListener { focusChange ->
mainHandler . post {
when ( focusChange ) {
AudioManager . AUDIOFOCUS _LOSS ,
AudioManager . AUDIOFOCUS _LOSS _TRANSIENT -> {
if ( status == " playing " ) {
pausedByAudioFocus = true
handlePause ( )
}
}
AudioManager . AUDIOFOCUS _GAIN -> {
if ( pausedByAudioFocus && status == " paused " ) {
pausedByAudioFocus = false
handleResume ( )
}
}
}
}
}
override fun onCreate ( ) {
super . onCreate ( )
notificationManager = NotificationManagerCompat . from ( this )
audioManager = getSystemService ( Context . AUDIO _SERVICE ) as AudioManager
createNotificationChannel ( )
setupMediaSession ( )
setupTextToSpeech ( )
mainHandler . postDelayed ( playbackHealthRunnable , HEALTH _CHECK _INTERVAL _MS )
publishSnapshot ( )
}
override fun onBind ( intent : Intent ? ) : IBinder ? = null
override fun onStartCommand ( intent : Intent ? , flags : Int , startId : Int ) : Int {
when ( intent ?. action ) {
ACTION _INIT -> {
backgroundModeEnabled = intent . getBooleanExtra (
EXTRA _BACKGROUND _MODE _ENABLED ,
backgroundModeEnabled ,
)
publishSnapshot ( )
}
ACTION _START _READING -> handleStartReading ( intent )
ACTION _PAUSE -> handlePause ( )
ACTION _RESUME -> handleResume ( )
ACTION _STOP -> handleStop (
clearContentKey = intent . getBooleanExtra ( EXTRA _CLEAR _CONTENT _KEY , true ) ,
)
ACTION _SKIP _FORWARD -> handleSkip ( 1 )
ACTION _SKIP _BACK -> handleSkip ( - 1 )
ACTION _SET _SPEED -> {
speed = intent . getDoubleExtra ( EXTRA _SPEED , speed )
applyVoiceAndSpeedSettings ( )
publishSnapshot ( )
}
ACTION _SET _VOICE -> {
voiceName = intent . getStringExtra ( EXTRA _VOICE _NAME )
language = intent . getStringExtra ( EXTRA _LANGUAGE ) ?: language
applyVoiceAndSpeedSettings ( )
publishSnapshot ( )
}
ACTION _SET _BACKGROUND _MODE -> {
backgroundModeEnabled = intent . getBooleanExtra (
EXTRA _BACKGROUND _MODE _ENABLED ,
backgroundModeEnabled ,
)
syncNotificationState ( )
publishSnapshot ( )
}
}
return START _STICKY
}
private fun setupTextToSpeech ( ) {
tts = TextToSpeech ( applicationContext , this )
tts ?. setOnUtteranceProgressListener (
object : UtteranceProgressListener ( ) {
override fun onStart ( utteranceId : String ? ) {
if ( utteranceId == null ) return
mainHandler . post {
if ( !is ActiveUtterance ( utteranceId ) ) return @post
if ( utteranceId != currentUtteranceId ) return @post
lastStartedUtterance = utteranceId
currentUtteranceStarted = true
currentSegmentRetry = 0
status = " playing "
scheduleUtteranceWatchdog ( utteranceId )
syncNotificationState ( )
publishSnapshot ( )
}
}
override fun onDone ( utteranceId : String ? ) {
if ( utteranceId == null ) return
mainHandler . post {
if ( !is ActiveUtterance ( utteranceId ) ) return @post
if ( utteranceId != currentUtteranceId ) return @post
clearUtteranceRuntimeState ( )
handleUtteranceCompleted ( parseUtteranceIndex ( utteranceId ) )
}
}
@Deprecated ( " Deprecated in Java " )
override fun onError ( utteranceId : String ? ) {
onError ( utteranceId , TextToSpeech . ERROR )
}
override fun onError ( utteranceId : String ? , errorCode : Int ) {
if ( utteranceId == null ) return
mainHandler . post {
if ( !is ActiveUtterance ( utteranceId ) ) return @post
if ( utteranceId != currentUtteranceId ) return @post
clearUtteranceRuntimeState ( )
handlePlaybackFailure ( )
}
}
} ,
)
}
override fun onInit ( initStatus : Int ) {
isTtsReady = initStatus == TextToSpeech . SUCCESS
if ( isTtsReady ) {
refreshAvailableVoices ( )
applyVoiceAndSpeedSettings ( )
if ( ( pendingReplayAfterInit || status == " playing " ) && segments . isNotEmpty ( ) ) {
pendingReplayAfterInit = false
speakCurrentSegment ( forceRestart = true )
}
} else {
status = " idle "
}
syncNotificationState ( )
publishSnapshot ( )
}
private fun refreshAvailableVoices ( ) {
val ttsInstance = tts ?: return
val vietnameseVoices = ttsInstance . voices
?. filter { voice -> voice . locale ?. toLanguageTag ( ) ?. lowercase ( ) ?. startsWith ( " vi " ) == true }
?. mapNotNull { voice ->
val locale = voice . locale ?. toLanguageTag ( ) ?: return @mapNotNull null
mapOf ( " name " to voice . name , " locale " to locale )
}
. orEmpty ( )
. distinctBy { voice -> " ${voice["name"]} : ${voice["locale"]} " }
. sortedBy { voice -> voice [ " name " ] }
availableVoices = vietnameseVoices
if ( voiceName . isNullOrBlank ( ) ) {
val preferred = vietnameseVoices . firstOrNull { voice ->
val normalized = voice [ " name " ] ?. lowercase ( ) . orEmpty ( )
normalized . contains ( " female " ) || normalized . contains ( " natural " )
} ?: vietnameseVoices . firstOrNull ( )
voiceName = preferred ?. get ( " name " )
language = preferred ?. get ( " locale " ) ?: language
}
}
private fun applyVoiceAndSpeedSettings ( ) {
val ttsInstance = tts ?: return
ttsInstance . setSpeechRate ( speed . toFloat ( ) )
val locale = language . toLocale ( )
ttsInstance . setLanguage ( locale )
val matchingVoice = ttsInstance . voices ?. firstOrNull { voice ->
voice . name == voiceName && voice . locale ?. toLanguageTag ( ) == language
}
if ( matchingVoice != null ) {
ttsInstance . voice = matchingVoice
}
}
private fun handleStartReading ( intent : Intent ) {
backgroundModeEnabled = intent . getBooleanExtra (
EXTRA _BACKGROUND _MODE _ENABLED ,
backgroundModeEnabled ,
)
speed = intent . getDoubleExtra ( EXTRA _SPEED , speed )
language = intent . getStringExtra ( EXTRA _LANGUAGE ) ?: language
voiceName = intent . getStringExtra ( EXTRA _VOICE _NAME )
contentKey = intent . getStringExtra ( EXTRA _CONTENT _KEY )
title = intent . getStringExtra ( EXTRA _TITLE )
segments = extractSegments ( intent )
currentIndex = intent . getIntExtra ( EXTRA _START _INDEX , 0 )
. coerceIn ( 0 , ( segments . size - 1 ) . coerceAtLeast ( 0 ) )
sessionGeneration += 1
clearUtteranceRuntimeState ( )
status = " playing "
pausedByAudioFocus = false
pendingReplayAfterInit = false
tts ?. stop ( )
publishSnapshot ( )
if ( !is TtsReady ) return
speakCurrentSegment ( forceRestart = true )
}
private fun handlePause ( ) {
if ( status != " playing " ) return
sessionGeneration += 1
clearUtteranceRuntimeState ( )
status = " paused "
pendingReplayAfterInit = false
tts ?. stop ( )
syncNotificationState ( )
publishSnapshot ( )
}
private fun handleResume ( ) {
if ( segments . isEmpty ( ) ) return
status = " playing "
sessionGeneration += 1
clearUtteranceRuntimeState ( )
pendingReplayAfterInit = false
publishSnapshot ( )
if ( !is TtsReady ) return
speakCurrentSegment ( forceRestart = true )
}
private fun handleStop ( clearContentKey : Boolean ) {
sessionGeneration += 1
clearUtteranceRuntimeState ( )
status = " idle "
currentIndex = 0
segments = emptyList ( )
title = null
if ( clearContentKey ) {
contentKey = null
}
tts ?. stop ( )
abandonAudioFocus ( )
syncNotificationState ( )
publishSnapshot ( )
stopSelf ( )
}
private fun handleSkip ( direction : Int ) {
if ( segments . isEmpty ( ) ) return
val nextIndex = ( currentIndex + direction ) . coerceIn ( 0 , segments . lastIndex )
if ( nextIndex == currentIndex && status == " idle " ) return
currentIndex = nextIndex
sessionGeneration += 1
clearUtteranceRuntimeState ( )
status = " playing "
pendingReplayAfterInit = false
tts ?. stop ( )
publishSnapshot ( )
if ( !is TtsReady ) return
speakCurrentSegment ( forceRestart = true )
}
private fun handleUtteranceCompleted ( completedIndex : Int ) {
if ( status != " playing " ) return
if ( completedIndex != currentIndex ) return
val nextIndex = currentIndex + 1
if ( nextIndex >= segments . size ) {
status = " idle "
currentIndex = 0
completedCount += 1
clearUtteranceRuntimeState ( )
abandonAudioFocus ( )
syncNotificationState ( )
publishSnapshot ( )
stopSelf ( )
return
}
currentIndex = nextIndex
speakCurrentSegment ( forceRestart = false )
}
private fun handlePlaybackFailure ( ) {
status = " idle "
clearUtteranceRuntimeState ( )
pendingReplayAfterInit = false
abandonAudioFocus ( )
syncNotificationState ( )
publishSnapshot ( )
stopSelf ( )
}
private fun speakCurrentSegment ( forceRestart : Boolean ) {
if ( segments . isEmpty ( ) || !is TtsReady ) return
if ( ! requestAudioFocus ( ) ) {
handlePlaybackFailure ( )
return
}
val segment = segments . getOrNull ( currentIndex ) ?: run {
handlePlaybackFailure ( )
return
}
applyVoiceAndSpeedSettings ( )
status = " playing "
// Reset retry counter when advancing to a new segment; keep it when retrying same segment.
if ( ! forceRestart ) {
currentSegmentRetry = 0
}
syncNotificationState ( )
publishSnapshot ( )
val utteranceId = " ${sessionGeneration} : ${currentIndex} : ${System.nanoTime()} "
lastStartedUtterance = if ( forceRestart ) null else lastStartedUtterance
currentUtteranceId = utteranceId
currentUtteranceStarted = false
lastSpeakRequestTimeMs = System . currentTimeMillis ( )
scheduleUtteranceWatchdog ( utteranceId )
val speakResult = if ( Build . VERSION . SDK _INT >= Build . VERSION_CODES . LOLLIPOP ) {
tts ?. speak ( segment . text , TextToSpeech . QUEUE _FLUSH , Bundle ( ) , utteranceId )
} else {
@Suppress ( " DEPRECATION " )
tts ?. speak ( segment . text , TextToSpeech . QUEUE _FLUSH , null )
}
if ( speakResult == TextToSpeech . ERROR ) {
recoverFromSilentPlayback ( " speak_error " )
}
}
private fun scheduleUtteranceWatchdog ( utteranceId : String ) {
clearUtteranceWatchdog ( )
val segment = currentSegment ( ) ?: return
val timeoutMs = estimateUtteranceTimeoutMs ( segment . text )
val guard = Runnable {
if ( status != " playing " ) return @Runnable
if ( utteranceId != currentUtteranceId ) return @Runnable
recoverFromSilentPlayback ( " watchdog_timeout " )
}
utteranceWatchdog = guard
mainHandler . postDelayed ( guard , timeoutMs )
}
private fun clearUtteranceWatchdog ( ) {
utteranceWatchdog ?. let ( mainHandler :: removeCallbacks )
utteranceWatchdog = null
}
private fun clearUtteranceRuntimeState ( ) {
clearUtteranceWatchdog ( )
lastStartedUtterance = null
currentUtteranceId = null
currentUtteranceStarted = false
consecutiveSilentHealthChecks = 0
}
private fun estimateUtteranceTimeoutMs ( text : String ) : Long {
val safeSpeed = speed . coerceIn ( 0.2 , 1.5 )
val multiplier = ( BASE _SPEED / safeSpeed ) . coerceIn ( 0.5 , 3.0 )
// Use 200ms/char (was 90ms) and a larger 10s buffer so the watchdog does not
// fire prematurely for longer Vietnamese sentences (e.g. ~150 chars ≈ 17 s at 0.9× ).
val estimate = ( text . length * 200L * multiplier ) . toLong ( ) + 10_000L
return estimate . coerceIn ( 15_000L , 180_000L )
}
private fun recoverFromSilentPlayback ( reason : String ) {
if ( status != " playing " ) return
Log . w ( TAG , " Recover from silent playback: $reason (index= $currentIndex retry= $currentSegmentRetry ) " )
if ( segments . isEmpty ( ) ) {
handlePlaybackFailure ( )
return
}
clearUtteranceRuntimeState ( )
if ( currentSegmentRetry >= 2 ) {
handlePlaybackFailure ( )
return
}
currentSegmentRetry += 1
if ( currentSegmentRetry >= 2 ) {
rebuildTtsEngineForRecovery ( reason )
return
}
tts ?. stop ( )
speakCurrentSegment ( forceRestart = true )
}
private fun rebuildTtsEngineForRecovery ( reason : String ) {
Log . w ( TAG , " Rebuilding TextToSpeech engine for recovery: $reason " )
pendingReplayAfterInit = true
isTtsReady = false
tts ?. stop ( )
tts ?. shutdown ( )
setupTextToSpeech ( )
}
private fun runPlaybackHealthCheck ( ) {
if ( status != " playing " ) return
if ( segments . isEmpty ( ) ) return
val ttsInstance = tts
if ( ttsInstance == null ) {
rebuildTtsEngineForRecovery ( " tts_instance_null " )
return
}
if ( !is TtsReady ) {
if ( ! pendingReplayAfterInit ) {
rebuildTtsEngineForRecovery ( " tts_not_ready " )
}
return
}
val isSpeaking = try {
ttsInstance . isSpeaking
} catch ( _ : Exception ) {
false
}
if ( ! currentUtteranceStarted ) {
if ( !is Speaking ) {
// Allow a grace period after speak() is called before flagging as silent.
// onStart typically fires within ~100 ms; 4 s covers slow TTS initialisation.
val elapsedSinceSpeak = System . currentTimeMillis ( ) - lastSpeakRequestTimeMs
if ( elapsedSinceSpeak > 4_000L ) {
recoverFromSilentPlayback ( " no_onStart_and_not_speaking " )
}
}
return
}
if ( isSpeaking ) {
consecutiveSilentHealthChecks = 0
return
}
consecutiveSilentHealthChecks += 1
if ( consecutiveSilentHealthChecks < 2 ) {
return
}
// Engine stopped speaking but onDone was never delivered; advance manually.
consecutiveSilentHealthChecks = 0
clearUtteranceRuntimeState ( )
handleUtteranceCompleted ( currentIndex )
}
private fun requestAudioFocus ( ) : Boolean {
return if ( Build . VERSION . SDK _INT >= Build . VERSION_CODES . O ) {
val request = audioFocusRequest
?: AudioFocusRequest . Builder ( AudioManager . AUDIOFOCUS _GAIN )
. setAudioAttributes (
AudioAttributes . Builder ( )
. setUsage ( AudioAttributes . USAGE _MEDIA )
. setContentType ( AudioAttributes . CONTENT _TYPE _SPEECH )
. build ( ) ,
)
. setAcceptsDelayedFocusGain ( false )
. setOnAudioFocusChangeListener ( audioFocusListener )
. build ( )
. also { audioFocusRequest = it }
audioManager . requestAudioFocus ( request ) == AudioManager . AUDIOFOCUS _REQUEST _GRANTED
} else {
@Suppress ( " DEPRECATION " )
audioManager . requestAudioFocus (
audioFocusListener ,
AudioManager . STREAM _MUSIC ,
AudioManager . AUDIOFOCUS _GAIN ,
) == AudioManager . AUDIOFOCUS _REQUEST _GRANTED
}
}
private fun abandonAudioFocus ( ) {
if ( Build . VERSION . SDK _INT >= Build . VERSION_CODES . O ) {
audioFocusRequest ?. let ( audioManager :: abandonAudioFocusRequest )
} else {
@Suppress ( " DEPRECATION " )
audioManager . abandonAudioFocus ( audioFocusListener )
}
}
private fun isActiveUtterance ( utteranceId : String ) : Boolean {
val generation = utteranceId . substringBefore ( ':' ) . toIntOrNull ( ) ?: return false
return generation == sessionGeneration
}
private fun parseUtteranceIndex ( utteranceId : String ) : Int {
val parts = utteranceId . split ( ':' )
return parts . getOrNull ( 1 ) ?. toIntOrNull ( ) ?: currentIndex
}
private fun currentSegment ( ) : ReaderTtsSegment ? = segments . getOrNull ( currentIndex )
private fun currentProgressLabel ( ) : String {
if ( segments . isEmpty ( ) ) return voiceName ?: language
return " Câu ${currentIndex + 1} / ${segments.size} "
}
private fun appLabel ( ) : String = applicationInfo . loadLabel ( packageManager ) . toString ( )
private fun buildLaunchIntent ( ) : PendingIntent ? {
val launchIntent = packageManager . getLaunchIntentForPackage ( packageName ) ?. apply {
flags = Intent . FLAG _ACTIVITY _SINGLE _TOP or Intent . FLAG _ACTIVITY _CLEAR _TOP
}
return launchIntent ?. let {
PendingIntent . getActivity (
this ,
100 ,
it ,
PendingIntent . FLAG _UPDATE _CURRENT or PendingIntent . FLAG _IMMUTABLE ,
)
}
}
private fun buildServicePendingIntent ( action : String ) : PendingIntent {
return PendingIntent . getService (
this ,
action . hashCode ( ) ,
Intent ( this , ReaderTtsMediaService :: class . java ) . apply {
this . action = action
if ( action == ACTION _STOP ) {
putExtra ( EXTRA _CLEAR _CONTENT _KEY , true )
}
} ,
PendingIntent . FLAG _UPDATE _CURRENT or PendingIntent . FLAG _IMMUTABLE ,
)
}
@SuppressLint ( " MissingPermission " )
private fun buildNotification ( ) = NotificationCompat . Builder ( this , CHANNEL _ID )
. setSmallIcon ( R . mipmap . ic _launcher )
. setContentTitle ( title ?: appLabel ( ) )
. setContentText ( currentProgressLabel ( ) )
. setContentIntent ( buildLaunchIntent ( ) )
. setDeleteIntent ( buildServicePendingIntent ( ACTION _STOP ) )
. setOnlyAlertOnce ( true )
. setOngoing ( status == " playing " )
. setVisibility ( NotificationCompat . VISIBILITY _PUBLIC )
. setCategory ( NotificationCompat . CATEGORY _TRANSPORT )
. addAction (
android . R . drawable . ic _media _previous ,
" Lùi câu " ,
buildServicePendingIntent ( ACTION _SKIP _BACK ) ,
)
. addAction (
if ( status == " playing " ) android . R . drawable . ic _media _pause else android . R . drawable . ic _media _play ,
if ( status == " playing " ) " Tạm dừng " else " Tiếp tục " ,
buildServicePendingIntent ( if ( status == " playing " ) ACTION _PAUSE else ACTION _RESUME ) ,
)
. addAction (
android . R . drawable . ic _menu _close _clear _cancel ,
" Dừng " ,
buildServicePendingIntent ( ACTION _STOP ) ,
)
. addAction (
android . R . drawable . ic _media _next ,
" Tới câu " ,
buildServicePendingIntent ( ACTION _SKIP _FORWARD ) ,
)
. setStyle (
MediaStyle ( )
. setMediaSession ( mediaSession . sessionToken )
. setShowActionsInCompactView ( 0 , 1 , 3 ) ,
)
. build ( )
private fun setupMediaSession ( ) {
mediaSession = MediaSessionCompat ( this , " ReaderTtsMediaSession " )
mediaSession . setCallback (
object : MediaSessionCompat . Callback ( ) {
override fun onPlay ( ) = handleResume ( )
override fun onPause ( ) = handlePause ( )
override fun onStop ( ) = handleStop ( clearContentKey = true )
override fun onSkipToNext ( ) = handleSkip ( 1 )
override fun onSkipToPrevious ( ) = handleSkip ( - 1 )
} ,
)
mediaSession . isActive = true
updateMediaSessionState ( )
}
private fun updateMediaSessionState ( ) {
val playbackState = when ( status ) {
" playing " -> PlaybackStateCompat . STATE _PLAYING
" paused " -> PlaybackStateCompat . STATE _PAUSED
else -> PlaybackStateCompat . STATE _STOPPED
}
val actions = PlaybackStateCompat . ACTION _PLAY or
PlaybackStateCompat . ACTION _PAUSE or
PlaybackStateCompat . ACTION _STOP or
PlaybackStateCompat . ACTION _SKIP _TO _NEXT or
PlaybackStateCompat . ACTION _SKIP _TO _PREVIOUS
mediaSession . setPlaybackState (
PlaybackStateCompat . Builder ( )
. setActions ( actions )
. setState ( playbackState , currentIndex . toLong ( ) , 1.0f )
. build ( ) ,
)
mediaSession . setMetadata (
MediaMetadataCompat . Builder ( )
. putString ( MediaMetadataCompat . METADATA _KEY _TITLE , title ?: appLabel ( ) )
. putString ( MediaMetadataCompat . METADATA _KEY _ARTIST , currentProgressLabel ( ) )
. build ( ) ,
)
}
@SuppressLint ( " MissingPermission " )
private fun syncNotificationState ( ) {
updateMediaSessionState ( )
if ( ! backgroundModeEnabled ) {
if ( isForegroundActive ) {
stopForeground ( true )
isForegroundActive = false
}
notificationManager . cancel ( NOTIFICATION _ID )
return
}
when ( status ) {
" playing " -> {
val notification = buildNotification ( )
if ( !is ForegroundActive ) {
startForeground ( NOTIFICATION _ID , notification )
isForegroundActive = true
} else {
notificationManager . notify ( NOTIFICATION _ID , notification )
}
}
" paused " -> {
val notification = buildNotification ( )
if ( isForegroundActive ) {
stopForeground ( false )
isForegroundActive = false
}
notificationManager . notify ( NOTIFICATION _ID , notification )
}
else -> {
if ( isForegroundActive ) {
stopForeground ( true )
isForegroundActive = false
}
notificationManager . cancel ( NOTIFICATION _ID )
}
}
}
private fun publishSnapshot ( ) {
val segment = currentSegment ( )
val canExposeSegmentProgress = status == " playing " && currentUtteranceStarted
ReaderTtsMediaBridge . publish (
hashMapOf (
" status " to status ,
" paragraphIndex " to currentIndex ,
" totalParagraphs " to segments . size ,
" activeParagraphIndex " to if ( canExposeSegmentProgress ) {
( segment ?. paragraphIndex ?: - 1 )
} else {
- 1
} ,
" progressStart " to if ( canExposeSegmentProgress ) {
( segment ?. start ?: - 1 )
} else {
- 1
} ,
" progressEnd " to if ( canExposeSegmentProgress ) {
( segment ?. end ?: - 1 )
} else {
- 1
} ,
" contentKey " to contentKey ,
" completedCount " to completedCount ,
" backgroundModeEnabled " to backgroundModeEnabled ,
" language " to language ,
" voiceName " to voiceName ,
" availableVietnameseVoices " to availableVoices ,
) ,
)
}
private fun createNotificationChannel ( ) {
if ( Build . VERSION . SDK _INT < Build . VERSION_CODES . O ) return
val manager = getSystemService ( Context . NOTIFICATION _SERVICE ) as NotificationManager
val channel = NotificationChannel (
CHANNEL _ID ,
CHANNEL _NAME ,
NotificationManager . IMPORTANCE _LOW ,
) . apply {
description = " Điều khiển đọc truyện bằng TTS "
setShowBadge ( false )
}
manager . createNotificationChannel ( channel )
}
private fun extractSegments ( intent : Intent ) : List < ReaderTtsSegment > {
return if ( Build . VERSION . SDK _INT >= Build . VERSION_CODES . TIRAMISU ) {
intent . getParcelableArrayListExtra ( EXTRA _SEGMENTS , ReaderTtsSegment :: class . java )
?: arrayListOf ( )
} else {
@Suppress ( " DEPRECATION " )
( intent . getParcelableArrayListExtra < ReaderTtsSegment > ( EXTRA _SEGMENTS )
?: arrayListOf ( ) )
}
}
override fun onDestroy ( ) {
mainHandler . removeCallbacks ( playbackHealthRunnable )
status = " idle "
currentIndex = 0
segments = emptyList ( )
clearUtteranceRuntimeState ( )
pendingReplayAfterInit = false
publishSnapshot ( )
tts ?. stop ( )
tts ?. shutdown ( )
abandonAudioFocus ( )
if ( isForegroundActive ) {
stopForeground ( true )
isForegroundActive = false
}
mediaSession . release ( )
super . onDestroy ( )
}
}
private fun String . toLocale ( ) : Locale {
val normalized = replace ( '_' , '-' )
return Locale . forLanguageTag ( normalized ) . takeIf { it . language . isNotBlank ( ) }
?: Locale ( " vi " , " VN " )
}