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:
Aryan 2026-05-15 22:36:51 +05:30 committed by GitHub
parent c0d0e57e79
commit b20ade9946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
247 changed files with 43321 additions and 7087 deletions

View file

@ -83,7 +83,9 @@ private const val PREFETCH_LOOKAHEAD = 3
class TtsPlaybackManager(
private val player: Player,
private val generateAudioChunk: suspend (bookTitle: String, chapterTitle: String?, chunkIndex: Int, totalChunks: Int, textChunk: String, speakerId: String, mode: TtsMode, authToken: String?) -> TtsAudioData,
private val onResetContext: () -> Unit
private val onResetContext: () -> Unit,
private val onPlaybackSessionPreparing: (bookTitle: String?, chapterTitle: String?) -> Unit = { _, _ -> },
private val onPlaybackSessionStopped: () -> Unit = {}
) : MediaSession.Callback, Player.Listener {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -415,6 +417,8 @@ class TtsPlaybackManager(
handleStopTts(clearState = false)
}
onPlaybackSessionPreparing(bookTitle, chapterTitle)
textChunks = chunks
currentSpeakerId = speakerId
currentTtsMode = ttsMode
@ -550,6 +554,7 @@ class TtsPlaybackManager(
val firstChunk = textChunks.getOrNull(startAtIndex)
if (firstChunk == null) {
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.")
onPlaybackSessionStopped()
return
}
@ -627,6 +632,7 @@ class TtsPlaybackManager(
isLoading = false,
errorMessage = ttsAudioData.error ?: "Failed to load audio."
)
onPlaybackSessionStopped()
}
}
@ -667,6 +673,7 @@ class TtsPlaybackManager(
Timber.tag(TTS_NOTIFICATION_DIAG_TAG).i(
"handleStopTts. clearState=$clearState, userInitiated=$userInitiated"
)
onPlaybackSessionStopped()
onResetContext()
preparationJob?.cancel()
wordTrackingJob?.cancel()

View file

@ -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()
}
}