Update v1.0.49 (#330)
* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE * Refactored desktop reader screens and state management into specialized components * Added image gallery to reader sidebar and refactored desktop PDF UI components * Implemented EPUB image gallery * Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic * Standardized UI styling and refactored app navigation layout * Implemented auto-hiding reader chrome and activity tracking in desktop * Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support. * Added PPTX support for desktop and refactored parsing into a shared module * Implement paid AI features and account management for the desktop application. * Implement AI Hub and enhanced Cloud TTS integration for Desktop * Implement streaming support for AI definition and summarization features * Implement support for password-protected PDFs and file actions in the desktop reader. * Implement cloud synchronization for desktop using Firestore and Google Drive * Implement PDF reflow and "Text View" for the desktop reader * Refactor OPDS logic to use SharedOpdsController * Optimize PDF tile rendering performance * Implement two-page spread support for PDF pagination * Implement two-page spread support for the PDF viewer * Improved shared spread zoom in PDF viewer * Improve PDF spread navigation with fling support and configurable page gaps * Add brightness control to PDF and EPUB readers * Refactor folder synchronization to use shared logic engine * Implement safe string formatting and validation for localized resources * Implement TTS chunk skip navigation * Implement deep-linking and playback controls for TTS media sessions * Implement start index for TTS playback * Improve TTS navigation, prefetching, and notification duration reporting * Implement TTS mini playback bar for background reading * Implement multi-window reader support for the desktop application * Improve desktop modal window management and visibility syncing * Implement localized string support for Desktop and shared UI * Implement language selection and persistence for Desktop * Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml * Implement localized banner messages and UI strings using resource-backed SharedText * Implement compact badge styling for small book covers * Refactor PDF native interaction and improve HTML import memory safety * fix language persistence * Refactor reader overflow menus to use section-based logic * Refactor PDF layout remapping and improve text box interaction * Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets * Centralize PDF annotation export mapping and improve metadata handling * Add support for threaded comments in PDF highlight annotations * Flatten highlight comments into a single thread for PDF export and allow author editing * Integrate page slider into reader chrome and persist toggle state * Handle fragments and queries in EPUB chapter paths * Implement dynamic, theme-aware coloring for the reader slider * Implement customizable app-wide font preference * Implement one-hand zoom gestures in the PDF viewer * Implement File Information dialog for PDF and EPUB readers * Bump version to 1.0.49 (53) * Refactor PDF reader logic into modular components * Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens * Add option to use PDF filenames as display names * Fix preservation of PDF filename display preference in library projection
This commit is contained in:
parent
dc5196526f
commit
9510293ac3
245 changed files with 37538 additions and 12460 deletions
198
app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt
Normal file
198
app/src/main/java/com/aryan/reader/tts/ReaderTtsMiniBar.kt
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package com.aryan.reader.tts
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
|
||||
|
||||
private const val TTS_MINI_BAR_EDGE_PADDING_DP = 16
|
||||
private const val TTS_MINI_BAR_MAIN_BOTTOM_PADDING_DP = 96
|
||||
|
||||
internal fun shouldShowReaderTtsMiniBar(
|
||||
ttsState: TtsState,
|
||||
isOnReaderRoute: Boolean
|
||||
): Boolean {
|
||||
if (isOnReaderRoute) return false
|
||||
if (ttsState.playbackSource != "READER") return false
|
||||
if (ttsState.sessionEndedByStop || ttsState.sessionFinished) return false
|
||||
return ttsState.isLoading || !ttsState.currentText.isNullOrBlank()
|
||||
}
|
||||
|
||||
internal fun readerTtsMiniBarBottomPaddingDp(isOnMainRoute: Boolean): Int {
|
||||
return if (isOnMainRoute) {
|
||||
TTS_MINI_BAR_MAIN_BOTTOM_PADDING_DP
|
||||
} else {
|
||||
TTS_MINI_BAR_EDGE_PADDING_DP
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun ReaderTtsMiniBar(
|
||||
ttsController: TtsController,
|
||||
ttsState: TtsState,
|
||||
onOpenReader: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val canOpenReader = !ttsState.bookId.isNullOrBlank()
|
||||
val canSkipPreviousChunk = !ttsState.isLoading &&
|
||||
ttsState.currentChunkIndex > 0 &&
|
||||
ttsState.totalChunks > 0
|
||||
val canSkipNextChunk = !ttsState.isLoading &&
|
||||
ttsState.currentChunkIndex >= 0 &&
|
||||
ttsState.currentChunkIndex < ttsState.totalChunks - 1
|
||||
val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) {
|
||||
if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) {
|
||||
"Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val title = ttsState.bookTitle
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.action_read_aloud)
|
||||
val subtitle = remember(title, ttsState.chapterTitle, chunkLabel) {
|
||||
listOfNotNull(
|
||||
ttsState.chapterTitle
|
||||
?.takeIf { it.isNotBlank() && it != title },
|
||||
chunkLabel
|
||||
).joinToString(" - ")
|
||||
}
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 8.dp,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 64.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.clickable(enabled = canOpenReader, onClick = onOpenReader)
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (subtitle.isNotBlank()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(4.dp))
|
||||
|
||||
IconButton(
|
||||
enabled = canSkipPreviousChunk,
|
||||
onClick = ttsController::skipToPreviousChunk,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SkipPrevious,
|
||||
contentDescription = stringResource(R.string.content_desc_tts_previous_chunk),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
if (ttsState.isPlaying) {
|
||||
ttsController.pause()
|
||||
} else {
|
||||
ttsController.resume()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(44.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
|
||||
contentDescription = stringResource(
|
||||
if (ttsState.isPlaying) {
|
||||
R.string.content_desc_pause_tts
|
||||
} else {
|
||||
R.string.content_desc_resume_tts
|
||||
}
|
||||
),
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
if (ttsState.isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
enabled = canSkipNextChunk,
|
||||
onClick = ttsController::skipToNextChunk,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SkipNext,
|
||||
contentDescription = stringResource(R.string.content_desc_tts_next_chunk),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -153,8 +153,11 @@ class TtsController(context: Context) : Player.Listener {
|
|||
bookTitle: String,
|
||||
chapterTitle: String?,
|
||||
coverImageUri: String?,
|
||||
bookId: String? = null,
|
||||
chapterIndex: Int? = null,
|
||||
totalChapters: Int? = null,
|
||||
pageIndex: Int? = null,
|
||||
startChunkIndex: Int = 0,
|
||||
continueSession: Boolean = false,
|
||||
ttsMode: TtsPlaybackManager.TtsMode,
|
||||
playbackSource: String = "READER",
|
||||
|
|
@ -184,8 +187,11 @@ class TtsController(context: Context) : Player.Listener {
|
|||
putString(KEY_BOOK_TITLE, bookTitle)
|
||||
putString(KEY_CHAPTER_TITLE, chapterTitle)
|
||||
putString(KEY_COVER_IMAGE_URI, coverImageUri)
|
||||
bookId?.let { putString(KEY_BOOK_ID, it) }
|
||||
chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) }
|
||||
totalChapters?.let { putInt(KEY_TOTAL_CHAPTERS, it) }
|
||||
pageIndex?.let { putInt(KEY_PAGE_INDEX, it) }
|
||||
putInt(KEY_START_CHUNK_INDEX, startChunkIndex)
|
||||
putBoolean(KEY_CONTINUE_SESSION, continueSession)
|
||||
putString(KEY_TTS_MODE, ttsMode.name)
|
||||
putString(KEY_PLAYBACK_SOURCE, playbackSource)
|
||||
|
|
@ -254,6 +260,16 @@ class TtsController(context: Context) : Player.Listener {
|
|||
mediaController?.sendCustomCommand(SLICE_CURRENT_AND_RELOAD_COMMAND, Bundle.EMPTY)
|
||||
}
|
||||
|
||||
fun skipToPreviousChunk() {
|
||||
Timber.d("UI sending SKIP_TO_PREVIOUS_TTS_CHUNK command.")
|
||||
mediaController?.sendCustomCommand(SKIP_TO_PREVIOUS_TTS_CHUNK_COMMAND, Bundle.EMPTY)
|
||||
}
|
||||
|
||||
fun skipToNextChunk() {
|
||||
Timber.d("UI sending SKIP_TO_NEXT_TTS_CHUNK command.")
|
||||
mediaController?.sendCustomCommand(SKIP_TO_NEXT_TTS_CHUNK_COMMAND, Bundle.EMPTY)
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"Controller onEvents. playbackState=${player.playbackState}, isPlaying=${player.isPlaying}, playWhenReady=${player.playWhenReady}, mediaItems=${player.mediaItemCount}, currentIndex=${player.currentMediaItemIndex}, events=$events"
|
||||
|
|
@ -266,6 +282,7 @@ class TtsController(context: Context) : Player.Listener {
|
|||
val customState = controller.customLayout.firstOrNull()?.extras ?: Bundle.EMPTY
|
||||
val currentMediaItem = controller.currentMediaItem
|
||||
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
|
||||
val currentMediaBookTitle = currentMediaItem?.mediaMetadata?.title?.toString()
|
||||
val currentTextFromMediaItem = mediaItemExtras?.getString("ttsText")
|
||||
?: currentMediaItem?.mediaMetadata?.subtitle?.toString()
|
||||
val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING
|
||||
|
|
@ -278,12 +295,17 @@ class TtsController(context: Context) : Player.Listener {
|
|||
val serviceChapterTitle = customState.getString("chapterTitle")
|
||||
val serviceChapterIndex = customState.getInt("chapterIndex", -1).takeIf { it >= 0 }
|
||||
val serviceTotalChapters = customState.getInt("totalChapters", -1).takeIf { it > 0 }
|
||||
val serviceBookId = customState.getString("bookId") ?: mediaItemExtras?.getString("bookId")
|
||||
val servicePageIndex = customState.getInt("pageIndex", -1)
|
||||
.takeIf { it >= 0 }
|
||||
?: mediaItemExtras?.getInt("pageIndex", -1)?.takeIf { it >= 0 }
|
||||
val serviceCurrentChunkIndex = customState.getInt("currentChunkIndex", -1)
|
||||
val serviceTotalChunks = customState.getInt("totalChunks", 0)
|
||||
val serviceBookProgressPercent = customState.getInt("bookProgressPercent", -1).takeIf { it >= 0 }
|
||||
|
||||
val sourceCfi = mediaItemExtras?.getString("sourceCfi")
|
||||
val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1
|
||||
val sourceCfi = mediaItemExtras?.getString("sourceCfi") ?: customState.getString("sourceCfi")
|
||||
val startOffset = mediaItemExtras?.getInt("startOffset", -1)
|
||||
?: customState.getInt("startOffset", -1)
|
||||
val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
|
||||
val currentWordStartOffset = customState.getInt("currentWordStartOffset", -1)
|
||||
val serviceMode = customState.getString("ttsMode", _ttsState.value.ttsMode)
|
||||
|
|
@ -298,8 +320,13 @@ class TtsController(context: Context) : Player.Listener {
|
|||
if (isLoading) currentState.currentText else null
|
||||
},
|
||||
errorMessage = customState.getString("errorMessage"),
|
||||
bookId = if (isPlaybackActive || isLoading) {
|
||||
serviceBookId ?: currentState.bookId
|
||||
} else {
|
||||
serviceBookId
|
||||
},
|
||||
bookTitle = if (isPlaybackActive) {
|
||||
currentMediaItem?.mediaMetadata?.artist?.toString() ?: serviceBookTitle
|
||||
currentMediaBookTitle ?: serviceBookTitle
|
||||
} else {
|
||||
if (isLoading) currentState.bookTitle else serviceBookTitle
|
||||
},
|
||||
|
|
@ -318,6 +345,11 @@ class TtsController(context: Context) : Player.Listener {
|
|||
} else {
|
||||
serviceTotalChapters
|
||||
},
|
||||
pageIndex = if (isPlaybackActive || isLoading) {
|
||||
servicePageIndex ?: currentState.pageIndex
|
||||
} else {
|
||||
servicePageIndex
|
||||
},
|
||||
currentChunkIndex = serviceCurrentChunkIndex,
|
||||
totalChunks = serviceTotalChunks,
|
||||
bookProgressPercent = serviceBookProgressPercent,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package com.aryan.reader.tts
|
||||
|
||||
const val ACTION_OPEN_TTS_SESSION = "com.aryan.reader.tts.OPEN_SESSION"
|
||||
const val EXTRA_TTS_BOOK_ID = "com.aryan.reader.tts.extra.BOOK_ID"
|
||||
const val EXTRA_TTS_CHAPTER_INDEX = "com.aryan.reader.tts.extra.CHAPTER_INDEX"
|
||||
const val EXTRA_TTS_SOURCE_CFI = "com.aryan.reader.tts.extra.SOURCE_CFI"
|
||||
const val EXTRA_TTS_START_OFFSET = "com.aryan.reader.tts.extra.START_OFFSET"
|
||||
const val EXTRA_TTS_PAGE_INDEX = "com.aryan.reader.tts.extra.PAGE_INDEX"
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -30,10 +30,16 @@ import android.content.pm.ServiceInfo
|
|||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.CommandButton
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaNotification
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.aryan.reader.R
|
||||
|
|
@ -61,6 +67,7 @@ import kotlinx.coroutines.channels.Channel
|
|||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import com.google.common.collect.ImmutableList
|
||||
|
||||
data class WordTimingInfo(val word: String, val startTime: Double)
|
||||
|
||||
|
|
@ -242,6 +249,227 @@ private const val TTS_FOREGROUND_CHANNEL_ID = "tts_playback"
|
|||
// Keep this aligned with Media3's default notification ID so playback updates replace the fallback.
|
||||
private const val TTS_FOREGROUND_NOTIFICATION_ID = 1001
|
||||
private const val TTS_FOREGROUND_IDLE_GRACE_MS = 15_000L
|
||||
private const val ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK = "com.aryan.reader.tts.NOTIFICATION_PREVIOUS_CHUNK"
|
||||
private const val ACTION_TTS_NOTIFICATION_NEXT_CHUNK = "com.aryan.reader.tts.NOTIFICATION_NEXT_CHUNK"
|
||||
private const val TTS_NOTIFICATION_PREVIOUS_REQUEST_CODE = 4208
|
||||
private const val TTS_NOTIFICATION_NEXT_REQUEST_CODE = 4209
|
||||
|
||||
@UnstableApi
|
||||
private class TtsMediaNotificationProvider(
|
||||
context: android.content.Context
|
||||
) : DefaultMediaNotificationProvider(
|
||||
context,
|
||||
{ _ -> TTS_FOREGROUND_NOTIFICATION_ID },
|
||||
TTS_FOREGROUND_CHANNEL_ID,
|
||||
R.string.tts_notification_channel_name
|
||||
) {
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
override fun addNotificationActions(
|
||||
mediaSession: MediaSession,
|
||||
mediaButtons: ImmutableList<CommandButton>,
|
||||
builder: NotificationCompat.Builder,
|
||||
actionFactory: MediaNotification.ActionFactory
|
||||
): IntArray {
|
||||
return super.addNotificationActions(
|
||||
mediaSession,
|
||||
mediaButtons,
|
||||
builder,
|
||||
TtsNotificationActionFactory(appContext, actionFactory)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
private class TtsNotificationActionFactory(
|
||||
private val context: android.content.Context,
|
||||
private val delegate: MediaNotification.ActionFactory
|
||||
) : MediaNotification.ActionFactory {
|
||||
override fun createMediaAction(
|
||||
mediaSession: MediaSession,
|
||||
icon: IconCompat,
|
||||
title: CharSequence,
|
||||
command: Int
|
||||
): NotificationCompat.Action {
|
||||
return when (command) {
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS,
|
||||
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> createChunkSkipAction(
|
||||
iconResId = R.drawable.skip_previous,
|
||||
title = title,
|
||||
action = ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK,
|
||||
requestCode = TTS_NOTIFICATION_PREVIOUS_REQUEST_CODE
|
||||
)
|
||||
Player.COMMAND_SEEK_TO_NEXT,
|
||||
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> createChunkSkipAction(
|
||||
iconResId = R.drawable.skip_next,
|
||||
title = title,
|
||||
action = ACTION_TTS_NOTIFICATION_NEXT_CHUNK,
|
||||
requestCode = TTS_NOTIFICATION_NEXT_REQUEST_CODE
|
||||
)
|
||||
else -> delegate.createMediaAction(mediaSession, icon, title, command)
|
||||
}
|
||||
}
|
||||
|
||||
override fun createCustomAction(
|
||||
mediaSession: MediaSession,
|
||||
icon: IconCompat,
|
||||
title: CharSequence,
|
||||
customAction: String,
|
||||
extras: android.os.Bundle
|
||||
): NotificationCompat.Action {
|
||||
return delegate.createCustomAction(mediaSession, icon, title, customAction, extras)
|
||||
}
|
||||
|
||||
override fun createCustomActionFromCustomCommandButton(
|
||||
mediaSession: MediaSession,
|
||||
customCommandButton: CommandButton
|
||||
): NotificationCompat.Action {
|
||||
return delegate.createCustomActionFromCustomCommandButton(mediaSession, customCommandButton)
|
||||
}
|
||||
|
||||
override fun createMediaActionPendingIntent(mediaSession: MediaSession, command: Long): PendingIntent {
|
||||
return delegate.createMediaActionPendingIntent(mediaSession, command)
|
||||
}
|
||||
|
||||
override fun createNotificationDismissalIntent(mediaSession: MediaSession): PendingIntent {
|
||||
return delegate.createNotificationDismissalIntent(mediaSession)
|
||||
}
|
||||
|
||||
private fun createChunkSkipAction(
|
||||
iconResId: Int,
|
||||
title: CharSequence,
|
||||
action: String,
|
||||
requestCode: Int
|
||||
): NotificationCompat.Action {
|
||||
val intent = Intent(context, TtsService::class.java).apply {
|
||||
this.action = action
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getService(
|
||||
context,
|
||||
requestCode,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Action.Builder(
|
||||
IconCompat.createWithResource(context, iconResId),
|
||||
title,
|
||||
pendingIntent
|
||||
)
|
||||
.setShowsUserInterface(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
private class TtsSessionPlayer(
|
||||
player: Player,
|
||||
private val canSkipToPreviousChunk: () -> Boolean,
|
||||
private val canSkipToNextChunk: () -> Boolean,
|
||||
private val skipToPreviousChunk: () -> Unit,
|
||||
private val skipToNextChunk: () -> Unit,
|
||||
private val isCurrentChunkStreaming: () -> Boolean,
|
||||
private val currentChunkDurationForNotification: (Long) -> Long
|
||||
) : ForwardingPlayer(player) {
|
||||
override fun getAvailableCommands(): Player.Commands {
|
||||
val builder = super.getAvailableCommands().buildUpon()
|
||||
if (canSkipToPreviousChunk()) {
|
||||
builder
|
||||
.add(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
} else {
|
||||
builder
|
||||
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
|
||||
.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
|
||||
}
|
||||
if (canSkipToNextChunk()) {
|
||||
builder
|
||||
.add(Player.COMMAND_SEEK_TO_NEXT)
|
||||
.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
} else {
|
||||
builder
|
||||
.remove(Player.COMMAND_SEEK_TO_NEXT)
|
||||
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun isCommandAvailable(command: Int): Boolean {
|
||||
return getAvailableCommands().contains(command)
|
||||
}
|
||||
|
||||
override fun hasPreviousMediaItem(): Boolean {
|
||||
return canSkipToPreviousChunk() || super.hasPreviousMediaItem()
|
||||
}
|
||||
|
||||
override fun hasNextMediaItem(): Boolean {
|
||||
return canSkipToNextChunk() || super.hasNextMediaItem()
|
||||
}
|
||||
|
||||
override fun seekToPrevious() {
|
||||
skipToPreviousChunk()
|
||||
}
|
||||
|
||||
override fun seekToPreviousMediaItem() {
|
||||
skipToPreviousChunk()
|
||||
}
|
||||
|
||||
override fun seekToNext() {
|
||||
skipToNextChunk()
|
||||
}
|
||||
|
||||
override fun seekToNextMediaItem() {
|
||||
skipToNextChunk()
|
||||
}
|
||||
|
||||
override fun getDuration(): Long {
|
||||
return notificationDurationMs().takeIf { it != C.TIME_UNSET } ?: super.getDuration()
|
||||
}
|
||||
|
||||
override fun getContentDuration(): Long {
|
||||
return getDuration()
|
||||
}
|
||||
|
||||
override fun getBufferedPosition(): Long {
|
||||
return adjustedStreamingBufferedPosition(super.getBufferedPosition())
|
||||
}
|
||||
|
||||
override fun getContentBufferedPosition(): Long {
|
||||
return getBufferedPosition()
|
||||
}
|
||||
|
||||
override fun getBufferedPercentage(): Int {
|
||||
val duration = notificationDurationMs()
|
||||
if (!isCurrentChunkStreaming() || duration == C.TIME_UNSET || duration <= 0L) {
|
||||
return super.getBufferedPercentage()
|
||||
}
|
||||
val bufferedPosition = getBufferedPosition().coerceIn(0L, duration)
|
||||
return ((bufferedPosition * 100L) / duration).toInt().coerceIn(0, 100)
|
||||
}
|
||||
|
||||
override fun isCurrentMediaItemDynamic(): Boolean {
|
||||
return if (isCurrentChunkStreaming()) false else super.isCurrentMediaItemDynamic()
|
||||
}
|
||||
|
||||
private fun notificationDurationMs(): Long {
|
||||
val currentPositionMs = super.getCurrentPosition().coerceAtLeast(0L)
|
||||
return currentChunkDurationForNotification(currentPositionMs)
|
||||
}
|
||||
|
||||
private fun adjustedStreamingBufferedPosition(delegatePositionMs: Long): Long {
|
||||
val duration = notificationDurationMs()
|
||||
if (!isCurrentChunkStreaming() || duration == C.TIME_UNSET || duration <= 0L) {
|
||||
return delegatePositionMs
|
||||
}
|
||||
val currentPositionMs = super.getCurrentPosition().coerceAtLeast(0L)
|
||||
val bufferedPositionMs = if (delegatePositionMs == C.TIME_UNSET || delegatePositionMs < currentPositionMs) {
|
||||
currentPositionMs
|
||||
} else {
|
||||
delegatePositionMs
|
||||
}
|
||||
return bufferedPositionMs.coerceIn(0L, duration)
|
||||
}
|
||||
}
|
||||
|
||||
@UnstableApi
|
||||
class TtsService : MediaSessionService() {
|
||||
|
|
@ -258,6 +486,29 @@ class TtsService : MediaSessionService() {
|
|||
private var foregroundChapterTitle: String? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_TTS_NOTIFICATION_PREVIOUS_CHUNK -> {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Notification previous chunk action received.")
|
||||
Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).i(
|
||||
"serviceAction=notification-previous hasPlaybackManager=${::playbackManager.isInitialized} playerInitialized=${::player.isInitialized}"
|
||||
)
|
||||
if (::playbackManager.isInitialized) {
|
||||
playbackManager.skipToPreviousChunkFromTransport()
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
ACTION_TTS_NOTIFICATION_NEXT_CHUNK -> {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Notification next chunk action received.")
|
||||
Timber.tag(TTS_CHUNK_NAV_DIAG_TAG).i(
|
||||
"serviceAction=notification-next hasPlaybackManager=${::playbackManager.isInitialized} playerInitialized=${::player.isInitialized}"
|
||||
)
|
||||
if (::playbackManager.isInitialized) {
|
||||
playbackManager.skipToNextChunkFromTransport()
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
}
|
||||
|
||||
val result = super.onStartCommand(intent, flags, startId)
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"onStartCommand. action=${intent?.action}, startId=$startId, result=$result"
|
||||
|
|
@ -747,6 +998,7 @@ class TtsService : MediaSessionService() {
|
|||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Timber.d("TtsService created.")
|
||||
setMediaNotificationProvider(TtsMediaNotificationProvider(this))
|
||||
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
|
|
@ -818,7 +1070,17 @@ class TtsService : MediaSessionService() {
|
|||
onPlaybackSessionStopped = ::onPlaybackSessionStopped
|
||||
)
|
||||
|
||||
mediaSession = MediaSession.Builder(this, player)
|
||||
val sessionPlayer = TtsSessionPlayer(
|
||||
player = player,
|
||||
canSkipToPreviousChunk = playbackManager::canSkipToPreviousChunk,
|
||||
canSkipToNextChunk = playbackManager::canSkipToNextChunk,
|
||||
skipToPreviousChunk = playbackManager::skipToPreviousChunkFromTransport,
|
||||
skipToNextChunk = playbackManager::skipToNextChunkFromTransport,
|
||||
isCurrentChunkStreaming = playbackManager::isCurrentChunkStreaming,
|
||||
currentChunkDurationForNotification = playbackManager::currentChunkDurationForNotification
|
||||
)
|
||||
|
||||
mediaSession = MediaSession.Builder(this, sessionPlayer)
|
||||
.setCallback(playbackManager)
|
||||
.build()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue