Desktop app (#308)
* Implement build profiles and feature policy for offline desktop builds * Introduce unified cross-platform Settings Hub * Refactor main settings into a hierarchical page-based navigation model * Refactor library projection to use shared multiplatform logic * Refactor UI state consumption by removing intermediate screen models * Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic * Refactor state management for tabs, selection, and pinning to use shared bridge logic * Refactor file type management and validation into a centralized shared module * Centralize file type resolution and improve handling of unknown types * Centralize book import logic with SharedImportPlanner * Refactor magnifier geometry logic and coordinate mapping * Properly handle orientation changes in scroll-locked PDF reader * Add screen orientation controls to EPUB and PDF readers * Implement right-to-left (RTL) pagination support and refactor reader menus * Separate right-to-left pagination settings for PDF and EPUB * Ensure PDF page data is scoped by document key for multi tab support * Implement theme-aware link styling for the epub reader * Implement jump history for back and forward navigation in the epub reader * Improve locator handling and navigation logic in paginated reader mode * Implement stable pagination navigation and location tracking * Centralize banner message management and auto-dismiss logic in MainViewModel * Implement zoom and pan state preservation for PDF pan lock mode * Enhance reader navigation UI and workspace layout management in desktop app * Refactor reader navigation sidebar and relocate search controls in desktop app * Enhance reader UI with redesigned selection menus and bottom sheet overlays * Implement custom highlight palettes and reader theme customization in desktop app * Implement cross-platform modal layer and refine reader UI styling * Improve highlight accuracy and implement metadata enrichment on book open in desktop app * Implement two-page spread layout for paginated reader on desktop * Implement persistent caching for book loading and pagination in desktop app * Implement persistent caching for book loading and pagination in desktop app * Optimize reader settings updates by separating layout and appearance changes in desktop app * Improve desktop window branding and native Windows styling * Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app * Refine selection handle positioning and interaction logic * Implement EPUB selection debug logging and improve handle targeting * Optimize desktop book loading performance and UI responsiveness * Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer. * Implement smooth zoom preview for the PDF reader in desktop app * Optimize PDF rendering performance and responsiveness in the desktop reader * Implement conditional diagnostic logging and update desktop build configuration * Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling * Added management options for annotations and highlights in the sidebar in desktop app * Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability. * Refined library filters and enhanced OPDS functionality in desktop app * Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app * Added PPTX support including document parsing, rendering, and indexing * Improved PPTX rendering and layout accuracy * Implemented text autofit support for PPTX rendering * Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity * Improved EPUB pagination accuracy and added layout telemetry in desktop app * Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app * Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling * Implemented billing account obfuscation * Implemented hierarchical folder navigation and improved library selection functionality in desktop app * Implemented platform-aware directory resolution and multi-platform native library support for desktop * Added full-screen mode for the reader workspace * Added PDF zoom indicator and interactive vertical scrollbar with page tooltips * Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management * Updated PDF indexing to replace existing page text and removed search result item keys * Implemented "preparing" foreground notification for TTS service * Optimized PDF rendering performance by pre-calculating page-specific annotations * Refactored desktop packaging tasks and improved distribution configuration * Optimized EPUB parser memory usage and added path traversal protection * Refactored WorkManager monitoring logic and added work pruning * Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks * Implemented bitmap size limits and scaling to prevent canvas rendering errors * Split long text paragraphs into multiple semantic blocks during HTML parsing * Implemented local ActionMode for text selection to prevent platform crashes * Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic * Added desktop startup splash screen and deferred WebView initialization * Reorganized settings hub and added separate PDF reader defaults * Implemented embedded cover extraction and metadata support for MOBI and FB2 formats * Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance. * Implemented procedurally generated book covers and replaced static placeholders * Redesigned search UI with a top bar and results overlay in desktop app * Added PDF page gap and overlay visibility options and implemented DesktopBookImporter * Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop * Implemented PDF viewport persistence for zoom and scroll positions in desktop app * Improved desktop fullscreen implementation and state restoration * Implemented desktop window state persistence * Implemented flavor-based branding and ProGuard configuration for desktop builds * Implemented precise reader positioning and improved highlight rendering logic in desktop app * Added support for user-editable book metadata * Enhanced book metadata support and integrated info/edit dialogs * Implemented embedded EPUB metadata editing * Improved highlight mapping and added custom scrollbar styling for the reader. * Reduced desktop WebView bundle size by excluding unused locales and runtime files * Added neutral pan mode as the default PDF interaction state. * Refactored library empty states and updated primary navigation tabs in desktop app * Implemented native paginated reader and unified content rendering architecture in desktop epub reader * Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing. * Improved pagination overflow detection in desktop * Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
parent
c0d0e57e79
commit
b20ade9946
247 changed files with 43321 additions and 7087 deletions
|
|
@ -20,9 +20,15 @@
|
|||
package com.aryan.reader.tts
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
|
|
@ -30,13 +36,16 @@ import androidx.media3.common.util.UnstableApi
|
|||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.GEMINI_CLOUD_TTS_MODEL
|
||||
import com.aryan.reader.isByokCloudTtsAvailable
|
||||
import com.aryan.reader.loadAiByokSettings
|
||||
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
|
|
@ -229,6 +238,11 @@ class InputStreamDataSource : androidx.media3.datasource.BaseDataSource(true) {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@UnstableApi
|
||||
class TtsService : MediaSessionService() {
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
|
@ -237,6 +251,24 @@ class TtsService : MediaSessionService() {
|
|||
private lateinit var playbackManager: TtsPlaybackManager
|
||||
private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer
|
||||
private lateinit var cacheManager: TtsCacheManager
|
||||
private var foregroundNotificationShown = false
|
||||
private var foregroundPlaybackExpected = false
|
||||
private var foregroundIdleJob: Job? = null
|
||||
private var foregroundBookTitle: String? = null
|
||||
private var foregroundChapterTitle: String? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val result = super.onStartCommand(intent, flags, startId)
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"onStartCommand. action=${intent?.action}, startId=$startId, result=$result"
|
||||
)
|
||||
val hasPreparedMedia = ::player.isInitialized && player.mediaItemCount > 0
|
||||
if (!hasPreparedMedia) {
|
||||
showPreparingForegroundNotification("onStartCommand")
|
||||
scheduleForegroundIdleStop(startId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
|
||||
val hasNotificationPermission = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
|
|
@ -252,21 +284,133 @@ class TtsService : MediaSessionService() {
|
|||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
|
||||
if (startInForegroundRequired) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing while foreground is required. Calling stopSelf().")
|
||||
stopSelf()
|
||||
} else {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Notification permission missing. Skipping notification update.")
|
||||
}
|
||||
|
||||
return
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w(
|
||||
"POST_NOTIFICATIONS is missing, but MediaSession notifications are exempt. Delegating notification update."
|
||||
)
|
||||
}
|
||||
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Delegating notification update to MediaSessionService.")
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
}
|
||||
|
||||
private fun showPreparingForegroundNotification(
|
||||
reason: String,
|
||||
bookTitle: String? = foregroundBookTitle,
|
||||
chapterTitle: String? = foregroundChapterTitle
|
||||
) {
|
||||
foregroundBookTitle = bookTitle
|
||||
foregroundChapterTitle = chapterTitle
|
||||
ensureTtsNotificationChannel()
|
||||
|
||||
try {
|
||||
val notification = buildPreparingForegroundNotification(bookTitle, chapterTitle)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
TTS_FOREGROUND_NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
|
||||
)
|
||||
} else {
|
||||
startForeground(TTS_FOREGROUND_NOTIFICATION_ID, notification)
|
||||
}
|
||||
foregroundNotificationShown = true
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i("Fallback foreground notification shown. reason=$reason")
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).e(e, "Failed to show fallback foreground notification. reason=$reason")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildPreparingForegroundNotification(bookTitle: String?, chapterTitle: String?): Notification {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
val contentIntent = launchIntent?.let {
|
||||
PendingIntent.getActivity(this, 0, it, pendingIntentFlags())
|
||||
}
|
||||
val title = bookTitle?.takeIf { it.isNotBlank() } ?: getString(R.string.app_name)
|
||||
val text = chapterTitle?.takeIf { it.isNotBlank() }
|
||||
?.let { getString(R.string.tts_notification_preparing_chapter, it) }
|
||||
?: getString(R.string.tts_notification_preparing)
|
||||
|
||||
return NotificationCompat.Builder(this, TTS_FOREGROUND_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_monochrome)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.apply {
|
||||
if (contentIntent != null) {
|
||||
setContentIntent(contentIntent)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun ensureTtsNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
val channel = NotificationChannel(
|
||||
TTS_FOREGROUND_CHANNEL_ID,
|
||||
getString(R.string.tts_notification_channel_name),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = getString(R.string.tts_notification_channel_desc)
|
||||
setShowBadge(false)
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun pendingIntentFlags(): Int {
|
||||
return PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
}
|
||||
|
||||
private fun scheduleForegroundIdleStop(startId: Int) {
|
||||
foregroundIdleJob?.cancel()
|
||||
foregroundIdleJob = scope.launch {
|
||||
delay(TTS_FOREGROUND_IDLE_GRACE_MS)
|
||||
val playbackInactive = !::player.isInitialized ||
|
||||
(!player.isPlaying && !player.playWhenReady && player.mediaItemCount == 0)
|
||||
if (!foregroundPlaybackExpected && playbackInactive) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w(
|
||||
"Foreground service start did not become an active TTS session. Stopping fallback foreground."
|
||||
)
|
||||
stopTtsForeground()
|
||||
stopSelf(startId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPlaybackSessionPreparing(bookTitle: String?, chapterTitle: String?) {
|
||||
foregroundPlaybackExpected = true
|
||||
foregroundIdleJob?.cancel()
|
||||
showPreparingForegroundNotification("START_TTS_COMMAND", bookTitle, chapterTitle)
|
||||
}
|
||||
|
||||
private fun onPlaybackSessionStopped() {
|
||||
foregroundPlaybackExpected = false
|
||||
foregroundIdleJob?.cancel()
|
||||
foregroundBookTitle = null
|
||||
foregroundChapterTitle = null
|
||||
stopTtsForeground()
|
||||
}
|
||||
|
||||
private fun stopTtsForeground() {
|
||||
if (!foregroundNotificationShown) return
|
||||
try {
|
||||
stopForeground(android.app.Service.STOP_FOREGROUND_REMOVE)
|
||||
} catch (e: Exception) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w(e, "Failed to stop fallback foreground notification.")
|
||||
} finally {
|
||||
foregroundNotificationShown = false
|
||||
}
|
||||
}
|
||||
|
||||
private val okHttpClient = OkHttpClient.Builder().build()
|
||||
private val liveClient by lazy {
|
||||
GeminiLiveClient(okHttpClient) { errorMsg ->
|
||||
|
|
@ -668,7 +812,9 @@ class TtsService : MediaSessionService() {
|
|||
playbackManager = TtsPlaybackManager(
|
||||
player = player,
|
||||
generateAudioChunk = audioGenerator,
|
||||
onResetContext = { liveClient.close() }
|
||||
onResetContext = { liveClient.close() },
|
||||
onPlaybackSessionPreparing = ::onPlaybackSessionPreparing,
|
||||
onPlaybackSessionStopped = ::onPlaybackSessionStopped
|
||||
)
|
||||
|
||||
mediaSession = MediaSession.Builder(this, player)
|
||||
|
|
@ -683,7 +829,7 @@ class TtsService : MediaSessionService() {
|
|||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
|
||||
"onTaskRemoved. playWhenReady=${if (::player.isInitialized) player.playWhenReady else null}, isPlaying=${if (::player.isInitialized) player.isPlaying else null}"
|
||||
)
|
||||
if (!player.playWhenReady) {
|
||||
if (!::player.isInitialized || !player.playWhenReady) {
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("Task removed while player is not playWhenReady. Calling stopSelf().")
|
||||
stopSelf()
|
||||
}
|
||||
|
|
@ -700,13 +846,26 @@ class TtsService : MediaSessionService() {
|
|||
override fun onDestroy() {
|
||||
Timber.d("TtsService is being destroyed.")
|
||||
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).w("TtsService onDestroy.")
|
||||
baseTtsSynthesizer.shutdown()
|
||||
playbackManager.release()
|
||||
mediaSession?.run {
|
||||
player.release()
|
||||
release()
|
||||
foregroundIdleJob?.cancel()
|
||||
stopTtsForeground()
|
||||
if (::baseTtsSynthesizer.isInitialized) {
|
||||
baseTtsSynthesizer.shutdown()
|
||||
}
|
||||
if (::playbackManager.isInitialized) {
|
||||
playbackManager.release()
|
||||
}
|
||||
var playerReleased = false
|
||||
mediaSession?.let { session ->
|
||||
if (::player.isInitialized) {
|
||||
player.release()
|
||||
playerReleased = true
|
||||
}
|
||||
session.release()
|
||||
mediaSession = null
|
||||
}
|
||||
if (!playerReleased && ::player.isInitialized) {
|
||||
player.release()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue