diff --git a/Cargo.lock b/Cargo.lock index 7639b174..5489bc4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7895,6 +7895,7 @@ dependencies = [ "keyring-core", "schemars 0.8.22", "serde", + "serde_json", "tauri", "tauri-plugin", "thiserror 2.0.18", diff --git a/apps/readest-app/public/locales/en/translation.json b/apps/readest-app/public/locales/en/translation.json index f466b2de..47dedeba 100644 --- a/apps/readest-app/public/locales/en/translation.json +++ b/apps/readest-app/public/locales/en/translation.json @@ -69,5 +69,6 @@ "Imported {{count}} annotations_one": "Imported {{count}} annotation", "Imported {{count}} annotations_other": "Imported {{count}} annotations", "Recently read": "Recently read", - "Show recently read": "Show recently read" + "Show recently read": "Show recently read", + "Your books will appear here": "Your books will appear here" } diff --git a/apps/readest-app/src-tauri/gen/apple/ReadestWidget/Info.plist b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/Info.plist new file mode 100644 index 00000000..20b67d35 --- /dev/null +++ b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/Info.plist @@ -0,0 +1,13 @@ + + + + + CFBundleDisplayNameReadest + CFBundleIdentifier$(PRODUCT_BUNDLE_IDENTIFIER) + CFBundlePackageType$(PRODUCT_BUNDLE_PACKAGE_TYPE) + NSExtension + + NSExtensionPointIdentifiercom.apple.widgetkit-extension + + + diff --git a/apps/readest-app/src-tauri/gen/apple/ReadestWidget/ReadestWidget.entitlements b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/ReadestWidget.entitlements new file mode 100644 index 00000000..2652585b --- /dev/null +++ b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/ReadestWidget.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.bilingify.readest + + + diff --git a/apps/readest-app/src-tauri/gen/apple/ReadestWidget/ReadestWidgetBundle.swift b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/ReadestWidgetBundle.swift new file mode 100644 index 00000000..f89d29f7 --- /dev/null +++ b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/ReadestWidgetBundle.swift @@ -0,0 +1,34 @@ +import WidgetKit +import SwiftUI + +struct ReadingWidget: Widget { + let kind = "ReadingWidget" + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: ReadingProvider()) { entry in + ReadingWidgetEntryView(entry: entry) + } + .configurationDisplayName("Readest") + .description("Continue reading your recent books.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + +struct ReadingWidgetEntryView: View { + @Environment(\.widgetFamily) var family + let entry: ReadingEntry + var body: some View { + content.widgetCardBackground() + } + + @ViewBuilder private var content: some View { + switch family { + case .systemSmall: SmallReadingView(snapshot: entry.snapshot) + default: RowReadingView(snapshot: entry.snapshot) + } + } +} + +@main +struct ReadestWidgetBundle: WidgetBundle { + var body: some Widget { ReadingWidget() } +} diff --git a/apps/readest-app/src-tauri/gen/apple/ReadestWidget/ReadingWidgetProvider.swift b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/ReadingWidgetProvider.swift new file mode 100644 index 00000000..d04da0f5 --- /dev/null +++ b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/ReadingWidgetProvider.swift @@ -0,0 +1,21 @@ +import WidgetKit +import SwiftUI + +struct ReadingEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot +} + +struct ReadingProvider: TimelineProvider { + func placeholder(in context: Context) -> ReadingEntry { + ReadingEntry(date: Date(), snapshot: WidgetSnapshotStore.load()) + } + func getSnapshot(in context: Context, completion: @escaping (ReadingEntry) -> Void) { + completion(ReadingEntry(date: Date(), snapshot: WidgetSnapshotStore.load())) + } + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + // One static entry; the app calls WidgetCenter.reloadAllTimelines() on change. + let entry = ReadingEntry(date: Date(), snapshot: WidgetSnapshotStore.load()) + completion(Timeline(entries: [entry], policy: .never)) + } +} diff --git a/apps/readest-app/src-tauri/gen/apple/ReadestWidget/ReadingWidgetViews.swift b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/ReadingWidgetViews.swift new file mode 100644 index 00000000..c9e04c61 --- /dev/null +++ b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/ReadingWidgetViews.swift @@ -0,0 +1,122 @@ +import WidgetKit +import SwiftUI + +extension View { + /// Adaptive widget card surface: paper-white in light mode, near-black in + /// dark mode. Uses containerBackground on iOS 17+ (required there) and a + /// plain background on the iOS 15/16 deployment floor. + @ViewBuilder + func widgetCardBackground() -> some View { + if #available(iOS 17.0, *) { + containerBackground(Color(.systemBackground), for: .widget) + } else { + background(Color(.systemBackground)) + } + } +} + +private func bookURL(_ hash: String) -> URL { URL(string: "readest://book/\(hash)")! } + +// MARK: - Progress Bar (overlaid along the bottom of the cover) +private struct ProgressBar: View { + let percent: Int + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(Color.white.opacity(0.35)) + Capsule().fill(Color.accentColor) + .frame(width: geo.size.width * CGFloat(min(100, max(0, percent))) / 100) + } + } + .frame(height: 3) + } +} + +// MARK: - Cover Cell (shared by all widget families) +// Center-cropped cover image fills its cell; a % badge sits top-right and a +// thin progress bar is overlaid along the bottom edge. Missing-cover fallback +// shows a tinted tile with the title centered. +private struct CoverCell: View { + let book: WidgetSnapshotBook + var body: some View { + ZStack(alignment: .topTrailing) { + // Cover + progress bar (clipped together to the rounded rect) + ZStack(alignment: .bottom) { + if let image = WidgetSnapshotStore.coverImage(for: book.hash) { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Color(.secondarySystemBackground) + Text(book.title) + .font(.caption2) + .lineLimit(3) + .multilineTextAlignment(.center) + .padding(6) + } + ProgressBar(percent: book.percent) + .padding(.horizontal, 6) + .padding(.bottom, 5) + } + .clipShape(RoundedRectangle(cornerRadius: 8)) + + // % badge – outside the clipShape so it is never trimmed + Text("\(book.percent)%") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.white) + .padding(.horizontal, 5) + .padding(.vertical, 3) + .background(Color.black.opacity(0.6)) + .clipShape(Capsule()) + .padding(5) + } + } +} + +// MARK: - Small Widget (single cover fills the entire widget) +struct SmallReadingView: View { + let snapshot: WidgetSnapshot + var body: some View { + if let book = snapshot.books.first { + CoverCell(book: book) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .widgetURL(bookURL(book.hash)) + } else { + EmptyReadingView(title: snapshot.emptyTitle) + } + } +} + +// MARK: - Medium/Large Widget (row of up to 3 covers, no header for a clean UI) +struct RowReadingView: View { + let snapshot: WidgetSnapshot + var body: some View { + if snapshot.books.isEmpty { + EmptyReadingView(title: snapshot.emptyTitle) + } else { + HStack(alignment: .top, spacing: 10) { + ForEach(snapshot.books.prefix(3)) { book in + Link(destination: bookURL(book.hash)) { + CoverCell(book: book) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity) + } + } + .padding(12) + } + } +} + +// MARK: - Empty State +struct EmptyReadingView: View { + let title: String + var body: some View { + VStack(spacing: 8) { + Image(systemName: "books.vertical").font(.title2).foregroundColor(.secondary) + Text(title).font(.system(size: 12)).foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding(12).frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/apps/readest-app/src-tauri/gen/apple/ReadestWidget/WidgetSnapshot.swift b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/WidgetSnapshot.swift new file mode 100644 index 00000000..bac1db5c --- /dev/null +++ b/apps/readest-app/src-tauri/gen/apple/ReadestWidget/WidgetSnapshot.swift @@ -0,0 +1,40 @@ +import Foundation +import UIKit + +struct WidgetSnapshotBook: Codable, Identifiable { + let hash: String + let title: String + let author: String + let percent: Int + var id: String { hash } +} + +struct WidgetSnapshot: Codable { + let books: [WidgetSnapshotBook] + let sectionTitle: String + let emptyTitle: String +} + +enum WidgetSnapshotStore { + static let suiteName = "group.com.bilingify.readest" + static let snapshotKey = "readingWidgetSnapshot" + + static func load() -> WidgetSnapshot { + guard + let data = UserDefaults(suiteName: suiteName)?.data(forKey: snapshotKey), + let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) + else { + return WidgetSnapshot(books: [], sectionTitle: "Continue reading", emptyTitle: "") + } + return snapshot + } + + static func coverImage(for hash: String) -> UIImage? { + guard + let container = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: suiteName) + else { return nil } + let url = container.appendingPathComponent("widget/covers/\(hash).jpg") + return UIImage(contentsOfFile: url.path) + } +} diff --git a/apps/readest-app/src-tauri/gen/apple/project.yml b/apps/readest-app/src-tauri/gen/apple/project.yml index 92efa52a..04bbd4ac 100644 --- a/apps/readest-app/src-tauri/gen/apple/project.yml +++ b/apps/readest-app/src-tauri/gen/apple/project.yml @@ -97,6 +97,9 @@ targets: - target: ShareExtension embed: true codeSign: true + - target: ReadestWidget + embed: true + codeSign: true - sdk: CoreGraphics.framework - sdk: Metal.framework - sdk: MetalKit.framework @@ -104,6 +107,7 @@ targets: - sdk: Security.framework - sdk: UIKit.framework - sdk: WebKit.framework + - sdk: WidgetKit.framework preBuildScripts: - script: pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?} name: Build Rust Code @@ -167,3 +171,35 @@ targets: ENABLE_BITCODE: false ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: false SKIP_INSTALL: true + + # WidgetKit extension that surfaces the user's current reading progress + # on the iOS home screen / Today view. Reads from the App Group suite + # written by the main app's useReadingWidget hook (Task 7). + ReadestWidget: + type: app-extension + platform: iOS + sources: + - path: ReadestWidget + info: + path: ReadestWidget/Info.plist + properties: + CFBundleDisplayName: Readest + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + settings: + base: + PRODUCT_NAME: ReadestWidget + PRODUCT_BUNDLE_IDENTIFIER: com.bilingify.readest.ReadestWidget + DEVELOPMENT_TEAM: J5W48D69VR + CODE_SIGN_ENTITLEMENTS: ReadestWidget/ReadestWidget.entitlements + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION: YES + SWIFT_VERSION: "5.0" + IPHONEOS_DEPLOYMENT_TARGET: 15.0 + ARCHS: [arm64] + VALID_ARCHS: arm64 + EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64 + ENABLE_BITCODE: false + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: false + SKIP_INSTALL: true + dependencies: + - sdk: WidgetKit.framework diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/Cargo.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/Cargo.toml index 4ced5ffe..6e52db8b 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/Cargo.toml +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/Cargo.toml @@ -17,6 +17,9 @@ serde = "1.0" thiserror = "2" schemars = "0.8" +[dev-dependencies] +serde_json = "1" + [build-dependencies] tauri-plugin = { version = "2", features = ["build"] } schemars = "0.8" diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/build.gradle.kts b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/build.gradle.kts index 17a24bdb..1f341f8f 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/build.gradle.kts +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/build.gradle.kts @@ -54,6 +54,7 @@ dependencies { // doesn't support modern API targets cleanly; alpha is widely used // in production and the API is stable. implementation("androidx.security:security-crypto:1.1.0-alpha06") + implementation("androidx.media:media:1.7.1") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/AndroidManifest.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/AndroidManifest.xml index c9419be9..fb3b09bb 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/AndroidManifest.xml +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/AndroidManifest.xml @@ -21,5 +21,15 @@ + + + + + + diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt index c4f060ac..86f5b6dc 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt @@ -114,6 +114,34 @@ class PurchaseProductRequestArgs { val productId: String? = null } +@InvokeArg +class UpdateReadingWidgetBookArgs { + var hash: String = "" + var title: String = "" + var author: String = "" + var percent: Int = 0 + var coverPath: String = "" +} + +@InvokeArg +class UpdateReadingWidgetTtsArgs { + var active: Boolean = false + var playing: Boolean = false +} + +@InvokeArg +class UpdateReadingWidgetRequestArgs { + var books: List = emptyList() + var sectionTitle: String = "" + var emptyTitle: String = "" + // Nullable — omitted from the snapshot when the caller does not send a tts object. + // Note: Tauri parseArgs uses Gson for deserialization; a nullable nested @InvokeArg + // field is set to null when the key is absent from the JSON payload, which is the + // expected behavior. If deserialization issues arise at runtime, fall back to two + // flat optional fields (ttsActive: Boolean? / ttsPlaying: Boolean?). + var tts: UpdateReadingWidgetTtsArgs? = null +} + data class ProductData( val id: String, val title: String, @@ -1055,6 +1083,40 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) { } } + @Command + fun update_reading_widget(invoke: Invoke) { + val args = invoke.parseArgs(UpdateReadingWidgetRequestArgs::class.java) + pluginScope.launch { + withContext(Dispatchers.IO) { + val books = org.json.JSONArray() + for (book in args.books) { + ReadingWidgetStore.writeThumbnail(activity, book.hash, book.coverPath, book.percent) + books.put( + org.json.JSONObject() + .put("hash", book.hash) + .put("title", book.title) + .put("author", book.author) + .put("percent", book.percent) + ) + } + val snapshot = org.json.JSONObject() + .put("books", books) + .put("sectionTitle", args.sectionTitle) + .put("emptyTitle", args.emptyTitle) + args.tts?.let { tts -> + snapshot.put( + "tts", + org.json.JSONObject() + .put("active", tts.active) + .put("playing", tts.playing) + ) + } + ReadingWidgetStore.writeSnapshot(activity, snapshot.toString()) + } + if (isActive) invoke.resolve() + } + } + // ── Sync passphrase keychain ────────────────────────────────────── // Backed by EncryptedSharedPreferences, which derives an AES-GCM // master key from AndroidKeystore and stores the value-of-keys map diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetProviders.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetProviders.kt new file mode 100644 index 00000000..165d4822 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetProviders.kt @@ -0,0 +1,117 @@ +package com.readest.native_bridge + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.graphics.BitmapFactory +import android.net.Uri +import android.widget.RemoteViews +import androidx.media.session.MediaButtonReceiver +import android.support.v4.media.session.PlaybackStateCompat +import org.json.JSONObject +import java.io.File + +private fun bookPendingIntent(context: Context, hash: String, requestCode: Int): PendingIntent { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("readest://book/$hash")) + .setPackage(context.packageName) + val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + return PendingIntent.getActivity(context, requestCode, intent, flags) +} + +private fun bookAt(snapshot: JSONObject, index: Int): JSONObject? { + val books = snapshot.optJSONArray("books") ?: return null + return if (index < books.length()) books.optJSONObject(index) else null +} + +private fun setCover(context: Context, views: RemoteViews, viewId: Int, hash: String) { + val file = File(ReadingWidgetStore.coversDir(context), "$hash.png") + val bitmap = if (file.exists()) BitmapFactory.decodeFile(file.absolutePath) else null + if (bitmap != null) views.setImageViewBitmap(viewId, bitmap) + else views.setImageViewResource(viewId, android.R.color.transparent) +} + +class ReadingWidgetProvider : AppWidgetProvider() { + private val coverIds = intArrayOf(R.id.cover0, R.id.cover1, R.id.cover2) + + override fun onUpdate(context: Context, mgr: AppWidgetManager, ids: IntArray) { + for (id in ids) updateWidget(context, mgr, id) + } + + override fun onAppWidgetOptionsChanged( + context: Context, mgr: AppWidgetManager, id: Int, newOptions: android.os.Bundle + ) { + updateWidget(context, mgr, id) + } + + private fun updateWidget(context: Context, mgr: AppWidgetManager, id: Int) { + val snapshot = ReadingWidgetStore.readSnapshot(context) + val opts = mgr.getAppWidgetOptions(id) + val minW = opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 110) + val minH = opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 110) + // Android grid: a span of n cells reports about (70*n - 30) dp, so + // n = (dp + 30) / 70. One book per column, capped at 3 (1 col -> 1, + // 2 -> 2, 3 and 4 -> 3). No header is shown, for a minimal UI. + val cols = ((minW + 30) / 70).coerceAtLeast(1).coerceAtMost(3) + val rows = ((minH + 30) / 70).coerceAtLeast(1) + + val views = RemoteViews(context.packageName, R.layout.widget_reading) + + val count = snapshot.optJSONArray("books")?.length() ?: 0 + if (count == 0) { + views.setViewVisibility(R.id.empty, android.view.View.VISIBLE) + views.setViewVisibility(R.id.row, android.view.View.GONE) + views.setTextViewText(R.id.empty, snapshot.optString("emptyTitle")) + } else { + views.setViewVisibility(R.id.empty, android.view.View.GONE) + views.setViewVisibility(R.id.row, android.view.View.VISIBLE) + for (i in coverIds.indices) { + val book = if (i < cols) bookAt(snapshot, i) else null + if (book == null) { + views.setViewVisibility(coverIds[i], android.view.View.GONE) + continue + } + val hash = book.optString("hash") + setCover(context, views, coverIds[i], hash) + views.setOnClickPendingIntent(coverIds[i], bookPendingIntent(context, hash, id * 10 + i)) + views.setViewVisibility(coverIds[i], android.view.View.VISIBLE) + } + } + // Only show the TTS controls when the widget has 2+ rows (no room in a + // single-row size). Add a little top padding in that case to balance the + // bottom control bar. + val tts = snapshot.optJSONObject("tts") + if (tts != null && tts.optBoolean("active") && rows >= 2) { + views.setViewVisibility(R.id.tts_bar, android.view.View.VISIBLE) + views.setViewVisibility(R.id.top_spacer, android.view.View.VISIBLE) + val playing = tts.optBoolean("playing") + views.setImageViewResource( + R.id.btn_play_pause, + if (playing) R.drawable.ic_widget_pause else R.drawable.ic_widget_play + ) + views.setOnClickPendingIntent( + R.id.btn_prev, + MediaButtonReceiver.buildMediaButtonPendingIntent( + context, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS + ) + ) + views.setOnClickPendingIntent( + R.id.btn_play_pause, + MediaButtonReceiver.buildMediaButtonPendingIntent( + context, PlaybackStateCompat.ACTION_PLAY_PAUSE + ) + ) + views.setOnClickPendingIntent( + R.id.btn_next, + MediaButtonReceiver.buildMediaButtonPendingIntent( + context, PlaybackStateCompat.ACTION_SKIP_TO_NEXT + ) + ) + } else { + views.setViewVisibility(R.id.tts_bar, android.view.View.GONE) + views.setViewVisibility(R.id.top_spacer, android.view.View.GONE) + } + mgr.updateAppWidget(id, views) + } +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetStore.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetStore.kt new file mode 100644 index 00000000..5291d7e0 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/ReadingWidgetStore.kt @@ -0,0 +1,147 @@ +package com.readest.native_bridge + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Shader +import android.graphics.Typeface +import org.json.JSONObject +import java.io.File +import kotlin.math.max + +object ReadingWidgetStore { + const val PREFS = "reading_widget" + const val KEY_SNAPSHOT = "snapshot" + private const val THUMB_WIDTH = 240 + private const val THUMB_HEIGHT = 360 + private const val CORNER_RADIUS = 18f + + fun coversDir(context: Context): File = + File(context.filesDir, "widget/covers").apply { mkdirs() } + + fun writeThumbnail(context: Context, hash: String, sourcePath: String, percent: Int) { + val dst = File(coversDir(context), "$hash.png") + val src = File(sourcePath) + if (!src.exists()) { dst.delete(); return } + // Note: skip-if-unchanged removed because the composite depends on the live percent. + + // Bounds pre-pass for memory safety before full decode. + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(sourcePath, bounds) + val longEdge = max(bounds.outWidth, bounds.outHeight).coerceAtLeast(1) + var sample = 1 + while (longEdge / sample > THUMB_HEIGHT * 2) sample *= 2 + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + val bitmap = BitmapFactory.decodeFile(sourcePath, opts) ?: return + + // Center-crop to 2:3 portrait aspect (width:height = 2:3). + val srcW = bitmap.width + val srcH = bitmap.height + val cropW: Int + val cropH: Int + if (srcW * 3 > srcH * 2) { + // Source is wider than 2:3 — crop the sides. + cropH = srcH + cropW = srcH * 2 / 3 + } else { + // Source is taller than 2:3 — crop top/bottom. + cropW = srcW + cropH = srcW * 3 / 2 + } + val cropX = (srcW - cropW) / 2 + val cropY = (srcH - cropH) / 2 + val cropped = Bitmap.createBitmap(bitmap, cropX, cropY, cropW, cropH) + bitmap.recycle() + + // Scale the cropped bitmap to the target size. + val scaled = Bitmap.createScaledBitmap(cropped, THUMB_WIDTH, THUMB_HEIGHT, true) + if (scaled !== cropped) cropped.recycle() + + // Apply rounded corners by drawing through a BitmapShader onto a transparent canvas. + val rounded = Bitmap.createBitmap(THUMB_WIDTH, THUMB_HEIGHT, Bitmap.Config.ARGB_8888) + val canvas = Canvas(rounded) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.shader = BitmapShader(scaled, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + canvas.drawRoundRect( + RectF(0f, 0f, THUMB_WIDTH.toFloat(), THUMB_HEIGHT.toFloat()), + CORNER_RADIUS, CORNER_RADIUS, + paint + ) + scaled.recycle() + + // Bake progress bar and % badge into the cover bitmap. + val w = rounded.width.toFloat() + val h = rounded.height.toFloat() + val pad = w * 0.05f + val pct = percent.coerceIn(0, 100) + + // progress bar along the bottom + val barH = w * 0.035f + val barTop = h - pad - barH + val barLeft = pad + val barRight = w - pad + val radius = barH / 2f + val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0x66000000 } + canvas.drawRoundRect(RectF(barLeft, barTop, barRight, barTop + barH), radius, radius, trackPaint) + val fillRight = barLeft + (barRight - barLeft) * (pct / 100f) + if (fillRight > barLeft + radius) { + val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xFF6A4BFF.toInt() } + canvas.drawRoundRect(RectF(barLeft, barTop, fillRight, barTop + barH), radius, radius, fillPaint) + } + + // % badge pill, top-right + val text = "$pct%" + val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0xFFFFFFFF.toInt() + textSize = w * 0.085f + typeface = Typeface.DEFAULT_BOLD + } + val fm = textPaint.fontMetrics + val tw = textPaint.measureText(text) + val padX = w * 0.03f + val padY = w * 0.02f + val pillW = tw + padX * 2f + val pillH = (fm.descent - fm.ascent) + padY * 2f + val pillRight = w - pad + val pillTop = pad + val pillLeft = pillRight - pillW + val pillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = 0xB3000000.toInt() } + val pillR = pillH / 2f + canvas.drawRoundRect(RectF(pillLeft, pillTop, pillRight, pillTop + pillH), pillR, pillR, pillPaint) + canvas.drawText(text, pillLeft + padX, pillTop + padY - fm.ascent, textPaint) + + // Write as PNG so the alpha channel for rounded corners is preserved. + dst.outputStream().use { rounded.compress(Bitmap.CompressFormat.PNG, 100, it) } + rounded.recycle() + } + + fun writeSnapshot(context: Context, json: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_SNAPSHOT, json).apply() + notifyWidgets(context) + } + + fun readSnapshot(context: Context): JSONObject { + val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_SNAPSHOT, null) ?: return JSONObject() + return runCatching { JSONObject(raw) }.getOrDefault(JSONObject()) + } + + private fun notifyWidgets(context: Context) { + val mgr = AppWidgetManager.getInstance(context) + val cls = ReadingWidgetProvider::class.java + val ids = mgr.getAppWidgetIds(ComponentName(context, cls)) + if (ids.isNotEmpty()) { + val intent = android.content.Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE) + intent.component = ComponentName(context, cls) + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) + context.sendBroadcast(intent) + } + } +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_pause.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_pause.xml new file mode 100644 index 00000000..5109c592 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_pause.xml @@ -0,0 +1,5 @@ + + + diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_play.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_play.xml new file mode 100644 index 00000000..22d19581 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_play.xml @@ -0,0 +1,5 @@ + + + diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_skip_next.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_skip_next.xml new file mode 100644 index 00000000..21207ea7 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_skip_next.xml @@ -0,0 +1,5 @@ + + + diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_skip_previous.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_skip_previous.xml new file mode 100644 index 00000000..e1e56b4b --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/ic_widget_skip_previous.xml @@ -0,0 +1,5 @@ + + + diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/widget_card_bg.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/widget_card_bg.xml new file mode 100644 index 00000000..5f65fe29 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/drawable/widget_card_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/layout/widget_reading.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/layout/widget_reading.xml new file mode 100644 index 00000000..03c28362 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/layout/widget_reading.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/xml/widget_reading_info.xml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/xml/widget_reading_info.xml new file mode 100644 index 00000000..907211f2 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/res/xml/widget_reading_info.xml @@ -0,0 +1,9 @@ + + diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs index f5d16e6b..87f5434b 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/build.rs @@ -38,6 +38,7 @@ const COMMANDS: &[&str] = &[ "get_secure_item", "clear_secure_item", "refresh_eink_screen", + "update_reading_widget", ]; fn main() { diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift index 36e4ef6f..06b8d0be 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift @@ -1502,6 +1502,26 @@ class NativeBridgePlugin: Plugin { @objc public func refresh_eink_screen(_ invoke: Invoke) { invoke.resolve(["success": false]) } + + @objc public func update_reading_widget(_ invoke: Invoke) { + guard let args = try? invoke.parseArgs(UpdateReadingWidgetRequestArgs.self) else { + return invoke.reject("Failed to parse arguments") + } + DispatchQueue.global(qos: .utility).async { + for book in args.books { + ReadingWidgetWriter.writeThumbnail(hash: book.hash, sourcePath: book.coverPath) + } + let snapshot = ReadingWidgetWriter.Snapshot( + books: args.books.map { + .init(hash: $0.hash, title: $0.title, author: $0.author, percent: $0.percent) + }, + sectionTitle: args.sectionTitle, + emptyTitle: args.emptyTitle + ) + ReadingWidgetWriter.write(snapshot: snapshot) + invoke.resolve() + } + } } /// Persistent store for security-scoped folder bookmarks. @@ -1731,6 +1751,19 @@ class ShowLookupPopoverArgs: Decodable { let word: String } +struct UpdateReadingWidgetBookArgs: Decodable { + let hash: String + let title: String + let author: String + let percent: Int + let coverPath: String +} +struct UpdateReadingWidgetRequestArgs: Decodable { + let books: [UpdateReadingWidgetBookArgs] + let sectionTitle: String + let emptyTitle: String +} + @_cdecl("init_plugin_native_bridge") func initPlugin() -> Plugin { return NativeBridgePlugin() diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/ReadingWidgetWriter.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/ReadingWidgetWriter.swift new file mode 100644 index 00000000..d4dd3194 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/ReadingWidgetWriter.swift @@ -0,0 +1,61 @@ +import Foundation +import UIKit +import WidgetKit + +enum ReadingWidgetWriter { + static let suiteName = "group.com.bilingify.readest" + static let snapshotKey = "readingWidgetSnapshot" + static let thumbMaxPixels: CGFloat = 240 + + struct SnapshotBook: Codable { + let hash: String + let title: String + let author: String + let percent: Int + } + struct Snapshot: Codable { + let books: [SnapshotBook] + let sectionTitle: String + let emptyTitle: String + } + + static var containerURL: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: suiteName) + } + + static func coversDir() -> URL? { + guard let base = containerURL?.appendingPathComponent("widget/covers", isDirectory: true) + else { return nil } + try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + return base + } + + /// Downsample a cover file to <= thumbMaxPixels on the long edge and write + /// it as JPEG into the App Group container. Returns silently on failure. + static func writeThumbnail(hash: String, sourcePath: String) { + guard let dir = coversDir() else { return } + let dst = dir.appendingPathComponent("\(hash).jpg") + guard let image = UIImage(contentsOfFile: sourcePath) else { + try? FileManager.default.removeItem(at: dst) + return + } + let longEdge = max(image.size.width, image.size.height) + let scale = longEdge > thumbMaxPixels ? thumbMaxPixels / longEdge : 1 + let target = CGSize(width: image.size.width * scale, height: image.size.height * scale) + let format = UIGraphicsImageRendererFormat() + format.scale = 1.0 + let renderer = UIGraphicsImageRenderer(size: target, format: format) + let resized = renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: target)) } + if let data = resized.jpegData(compressionQuality: 0.8) { + try? data.write(to: dst) + } + } + + static func write(snapshot: Snapshot) { + guard let data = try? JSONEncoder().encode(snapshot) else { return } + UserDefaults(suiteName: suiteName)?.set(data, forKey: snapshotKey) + DispatchQueue.main.async { + WidgetCenter.shared.reloadAllTimelines() + } + } +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/update_reading_widget.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/update_reading_widget.toml new file mode 100644 index 00000000..87588863 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/update_reading_widget.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-update-reading-widget" +description = "Enables the update_reading_widget command without any pre-configured scope." +commands.allow = ["update_reading_widget"] + +[[permission]] +identifier = "deny-update-reading-widget" +description = "Denies the update_reading_widget command without any pre-configured scope." +commands.deny = ["update_reading_widget"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md index 1c1c3eec..b042e7d1 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md @@ -46,6 +46,7 @@ Default permissions for the plugin - `allow-get-secure-item` - `allow-clear-secure-item` - `allow-refresh-eink-screen` +- `allow-update-reading-widget` ## Permission Table @@ -1203,6 +1204,32 @@ Denies the show_lookup_popover command without any pre-configured scope. +`native-bridge:allow-update-reading-widget` + + + + +Enables the update_reading_widget command without any pre-configured scope. + + + + + + + +`native-bridge:deny-update-reading-widget` + + + + +Denies the update_reading_widget command without any pre-configured scope. + + + + + + + `native-bridge:allow-use-background-audio` diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml index 1692f67d..6d1d6b4f 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml @@ -43,4 +43,5 @@ permissions = [ "allow-get-secure-item", "allow-clear-secure-item", "allow-refresh-eink-screen", + "allow-update-reading-widget", ] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json index 4a4460d1..7a7b0678 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json @@ -822,6 +822,18 @@ "const": "deny-show-lookup-popover", "markdownDescription": "Denies the show_lookup_popover command without any pre-configured scope." }, + { + "description": "Enables the update_reading_widget command without any pre-configured scope.", + "type": "string", + "const": "allow-update-reading-widget", + "markdownDescription": "Enables the update_reading_widget command without any pre-configured scope." + }, + { + "description": "Denies the update_reading_widget command without any pre-configured scope.", + "type": "string", + "const": "deny-update-reading-widget", + "markdownDescription": "Denies the update_reading_widget command without any pre-configured scope." + }, { "description": "Enables the use_background_audio command without any pre-configured scope.", "type": "string", @@ -835,10 +847,10 @@ "markdownDescription": "Denies the use_background_audio command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`\n- `allow-update-reading-widget`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-save-image-to-gallery`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-show-lookup-popover`\n- `allow-get-lookup-dictionary`\n- `allow-clear-lookup-dictionary`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`\n- `allow-set-secure-item`\n- `allow-get-secure-item`\n- `allow-clear-secure-item`\n- `allow-refresh-eink-screen`\n- `allow-update-reading-widget`" } ] } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs index ca5f5023..dcda99d4 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs @@ -280,3 +280,11 @@ pub(crate) async fn refresh_eink_screen( ) -> Result { app.native_bridge().refresh_eink_screen() } + +#[command] +pub(crate) async fn update_reading_widget( + app: AppHandle, + payload: UpdateReadingWidgetRequest, +) -> Result<()> { + app.native_bridge().update_reading_widget(payload) +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs index 42e3f398..ff7449ee 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs @@ -351,6 +351,14 @@ impl NativeBridge { pub fn refresh_eink_screen(&self) -> crate::Result { Err(crate::Error::UnsupportedPlatformError) } + + pub fn update_reading_widget( + &self, + _payload: UpdateReadingWidgetRequest, + ) -> crate::Result<()> { + // Home-screen widgets are mobile-only; desktop is a no-op. + Ok(()) + } } const KEYRING_SERVICE: &str = "Readest Safe Storage"; diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs index 37e568e1..90c250d1 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs @@ -89,6 +89,7 @@ pub fn init() -> TauriPlugin { commands::get_secure_item, commands::clear_secure_item, commands::refresh_eink_screen, + commands::update_reading_widget, ]) .setup(|app, api| { #[cfg(mobile)] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs index 25c51e97..4ba5d6a9 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs @@ -353,3 +353,11 @@ impl NativeBridge { .map_err(Into::into) } } + +impl NativeBridge { + pub fn update_reading_widget(&self, payload: UpdateReadingWidgetRequest) -> crate::Result<()> { + self.0 + .run_mobile_plugin("update_reading_widget", payload) + .map_err(Into::into) + } +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs index 34362b16..2bab7153 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs @@ -405,3 +405,30 @@ pub struct RefreshEinkScreenResponse { pub success: bool, pub error: Option, } + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadingWidgetBook { + pub hash: String, + pub title: String, + pub author: String, + pub percent: u8, + pub cover_path: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadingWidgetTts { + pub active: bool, + pub playing: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateReadingWidgetRequest { + pub books: Vec, + pub section_title: String, + pub empty_title: String, + #[serde(default)] + pub tts: Option, +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/tests/reading_widget.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/tests/reading_widget.rs new file mode 100644 index 00000000..1a14a5f0 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/tests/reading_widget.rs @@ -0,0 +1,41 @@ +use tauri_plugin_native_bridge::UpdateReadingWidgetRequest; + +#[test] +fn deserializes_camel_case_payload() { + let json = r#"{ + "books": [{"hash":"h1","title":"T","author":"A","percent":72,"coverPath":"/x/h1/cover.png"}], + "sectionTitle": "Continue reading", + "emptyTitle": "Your books will appear here" + }"#; + let req: UpdateReadingWidgetRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.books.len(), 1); + assert_eq!(req.books[0].percent, 72); + assert_eq!(req.books[0].cover_path, "/x/h1/cover.png"); + assert_eq!(req.section_title, "Continue reading"); + assert!(req.tts.is_none()); +} + +#[test] +fn deserializes_tts_field_when_present() { + let json = r#"{ + "books": [], + "sectionTitle": "S", + "emptyTitle": "E", + "tts": {"active": true, "playing": false} + }"#; + let req: UpdateReadingWidgetRequest = serde_json::from_str(json).unwrap(); + let tts = req.tts.expect("tts should be Some"); + assert_eq!(tts.active, true); + assert_eq!(tts.playing, false); +} + +#[test] +fn tts_is_none_when_absent() { + let json = r#"{ + "books": [], + "sectionTitle": "S", + "emptyTitle": "E" + }"#; + let req: UpdateReadingWidgetRequest = serde_json::from_str(json).unwrap(); + assert!(req.tts.is_none()); +} diff --git a/apps/readest-app/src/__tests__/services/readingWidget.test.ts b/apps/readest-app/src/__tests__/services/readingWidget.test.ts new file mode 100644 index 00000000..b6ae73ed --- /dev/null +++ b/apps/readest-app/src/__tests__/services/readingWidget.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + selectReadingWidgetBooks, + computeReadingPercent, + buildReadingWidgetPayload, +} from '@/services/widget/readingWidget'; +import type { Book } from '@/types/book'; + +vi.mock('@/utils/bridge', () => ({ updateReadingWidget: vi.fn().mockResolvedValue(undefined) })); +vi.mock('@/store/libraryStore', () => ({ + useLibraryStore: { + getState: () => ({ + library: [ + { + hash: 'a', + title: 'Ta', + author: 'Aa', + format: 'EPUB', + updatedAt: 2, + progress: [1, 2], + readingStatus: 'reading', + }, + { + hash: 'b', + title: 'Tb', + author: 'Ab', + format: 'EPUB', + updatedAt: 5, + readingStatus: 'finished', + }, + ], + }), + }, +})); + +const mk = (over: Partial): Book => + ({ hash: 'h', title: 'T', author: 'A', format: 'EPUB', updatedAt: 0, ...over }) as Book; + +describe('computeReadingPercent', () => { + it('rounds current/total to a 0-100 integer', () => { + expect(computeReadingPercent(mk({ progress: [72, 100] }))).toBe(72); + expect(computeReadingPercent(mk({ progress: [1, 3] }))).toBe(33); + }); + it('is 0 when progress missing or total is 0', () => { + expect(computeReadingPercent(mk({}))).toBe(0); + expect(computeReadingPercent(mk({ progress: [1, 0] }))).toBe(0); + }); + it('clamps to 100', () => { + expect(computeReadingPercent(mk({ progress: [120, 100] }))).toBe(100); + }); +}); + +describe('selectReadingWidgetBooks', () => { + it('keeps non-deleted, non-finished, non-abandoned and sorts by updatedAt desc', () => { + const books = [ + mk({ hash: 'a', updatedAt: 10, readingStatus: 'reading' }), + mk({ hash: 'b', updatedAt: 30, readingStatus: 'unread' }), + mk({ hash: 'c', updatedAt: 20, readingStatus: 'finished' }), + mk({ hash: 'd', updatedAt: 40, readingStatus: 'abandoned' }), + mk({ hash: 'e', updatedAt: 50, deletedAt: 123 }), + ]; + expect(selectReadingWidgetBooks(books).map((b) => b.hash)).toEqual(['b', 'a']); + }); + it('caps at the limit', () => { + const books = [1, 2, 3, 4].map((n) => mk({ hash: String(n), updatedAt: n })); + expect(selectReadingWidgetBooks(books, 3)).toHaveLength(3); + }); +}); + +import { refreshReadingWidget } from '@/services/widget/readingWidget'; + +const appServiceForBuild = { + isMobileApp: true, + resolveFilePath: vi.fn().mockResolvedValue('/data/Books'), +} as unknown as import('@/types/system').AppService; + +const labelsForBuild = { sectionTitle: 'Continue reading', emptyTitle: 'Empty' }; +const booksForBuild: Book[] = [mk({ hash: 'x', updatedAt: 1, progress: [1, 4] })]; + +describe('buildReadingWidgetPayload', () => { + it('includes tts field when provided', async () => { + const payload = await buildReadingWidgetPayload( + booksForBuild, + appServiceForBuild, + labelsForBuild, + { + active: true, + playing: false, + }, + ); + expect(payload.tts).toEqual({ active: true, playing: false }); + }); + + it('omits tts key when not provided', async () => { + const payload = await buildReadingWidgetPayload( + booksForBuild, + appServiceForBuild, + labelsForBuild, + ); + expect('tts' in payload).toBe(false); + }); +}); + +describe('refreshReadingWidget', () => { + const appService = { + isMobileApp: true, + resolveFilePath: vi.fn().mockResolvedValue('/data/Books'), + } as unknown as import('@/types/system').AppService; + + it('skips when not a mobile app', async () => { + const { updateReadingWidget } = await import('@/utils/bridge'); + await refreshReadingWidget({ ...appService, isMobileApp: false } as never, { + sectionTitle: 'Continue reading', + emptyTitle: 'Empty', + }); + expect(updateReadingWidget).not.toHaveBeenCalled(); + }); + + it('selects in-progress books and resolves cover paths', async () => { + const { updateReadingWidget } = await import('@/utils/bridge'); + await refreshReadingWidget(appService, { + sectionTitle: 'Continue reading', + emptyTitle: 'Empty', + }); + expect(updateReadingWidget).toHaveBeenCalledWith({ + books: [ + { hash: 'a', title: 'Ta', author: 'Aa', percent: 50, coverPath: '/data/Books/a/cover.png' }, + ], + sectionTitle: 'Continue reading', + emptyTitle: 'Empty', + }); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/deeplink-book.test.ts b/apps/readest-app/src/__tests__/utils/deeplink-book.test.ts new file mode 100644 index 00000000..bce40a21 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/deeplink-book.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { parseBookDeepLink } from '@/utils/deeplink'; + +describe('parseBookDeepLink', () => { + it('parses the custom-scheme book-open form', () => { + expect(parseBookDeepLink('readest://book/abc123')).toEqual({ bookHash: 'abc123' }); + }); + it('parses the web form', () => { + expect(parseBookDeepLink('https://web.readest.com/o/book/abc123')).toEqual({ + bookHash: 'abc123', + }); + }); + it('does NOT match the annotation form', () => { + expect(parseBookDeepLink('readest://book/abc123/annotation/n1')).toBeNull(); + }); + it('ignores unrelated urls', () => { + expect(parseBookDeepLink('readest://share/tok')).toBeNull(); + expect(parseBookDeepLink('not a url')).toBeNull(); + }); +}); diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index 46bbd9fa..2f13ec45 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -47,6 +47,8 @@ import { getLibraryViewSettings } from '@/helpers/settings'; import { useAppUrlIngress } from '@/hooks/useAppUrlIngress'; import { useOpenWithBooks } from '@/hooks/useOpenWithBooks'; import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink'; +import { useOpenBookLink } from '@/hooks/useOpenBookLink'; +import { useReadingWidget } from '@/hooks/useReadingWidget'; import { useOpenShareLink } from '@/hooks/useOpenShareLink'; import { useClipUrlIngress } from '@/hooks/useClipUrlIngress'; import { useKeyDownActions } from '@/hooks/useKeyDownActions'; @@ -266,6 +268,8 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP useAppUrlIngress(); useOpenWithBooks(); useOpenAnnotationLink(); + useOpenBookLink(); + useReadingWidget(); useOpenShareLink(); useClipUrlIngress(); useTransferQueue(libraryLoaded); diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index e6cf550b..f7f1128f 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -203,6 +203,20 @@ const FoliateViewer: React.FC<{ // Always stash the latest detail; if another rAF is already pending // it'll pick this up and the intermediate states are skipped. pendingRelocateRef.current = event as CustomEvent; + // requestAnimationFrame is paused while the WebView is backgrounded, so the + // rAF-coalesced commit below would never run during background TTS - which + // freezes book.progress (and readerProgressStore, and the home-screen + // widget that reads them). Commit synchronously when hidden so progress + // stays current. The page-follow relocate still fires; only the commit was + // being deferred. + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + if (relocateRafRef.current != null) { + cancelAnimationFrame(relocateRafRef.current); + relocateRafRef.current = null; + } + commitRelocate(); + return; + } if (relocateRafRef.current != null) return; relocateRafRef.current = requestAnimationFrame(commitRelocate); }; diff --git a/apps/readest-app/src/app/reader/hooks/useBooksManager.ts b/apps/readest-app/src/app/reader/hooks/useBooksManager.ts index eca8646d..7211831b 100644 --- a/apps/readest-app/src/app/reader/hooks/useBooksManager.ts +++ b/apps/readest-app/src/app/reader/hooks/useBooksManager.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { useEnv } from '@/context/EnvContext'; import { useReaderStore } from '@/store/readerStore'; @@ -6,6 +6,7 @@ import { useSidebarStore } from '@/store/sidebarStore'; import { uniqueId } from '@/utils/misc'; import { useParallelViewStore } from '@/store/parallelViewStore'; import { navigateToReader } from '@/utils/nav'; +import { eventDispatcher } from '@/utils/event'; const useBooksManager = () => { const router = useRouter(); @@ -41,6 +42,37 @@ const useBooksManager = () => { setShouldUpdateSearchParams(true); }; + // Open a book in-place when the widget taps a book while a reader is already + // mounted. REPLACE the open book(s) with the tapped one (single ids=) + // rather than appending: appending produced ids=a+b which, with the OS + // re-delivering the launch deep link, looped. The store update renders the + // new book immediately; closing the previous key follows the same path as + // dismissBook. + const openBookInReader = (bookHash: string) => { + const existing = bookKeys.find((key) => key.startsWith(bookHash)); + if (existing) { + setSideBarBookKey(existing); + return; + } + const newKey = `${bookHash}-${uniqueId()}`; + initViewState(envConfig, bookHash, newKey, true); + setBookKeys([newKey]); + setSideBarBookKey(newKey); + setShouldUpdateSearchParams(true); + }; + + // Stable ref so the listener calls the latest closure without re-subscribing. + const openBookRef = useRef(openBookInReader); + openBookRef.current = openBookInReader; + useEffect(() => { + const handle = (event: CustomEvent) => { + const { bookHash } = event.detail as { bookHash: string }; + openBookRef.current(bookHash); + }; + eventDispatcher.on('open-book-in-reader', handle); + return () => eventDispatcher.off('open-book-in-reader', handle); + }, []); + // Close a book and sync with bookKeys and URL const dismissBook = (bookKey: string) => { const updatedKeys = bookKeys.filter((key) => key !== bookKey); diff --git a/apps/readest-app/src/app/reader/page.tsx b/apps/readest-app/src/app/reader/page.tsx index c385b84b..a66c82da 100644 --- a/apps/readest-app/src/app/reader/page.tsx +++ b/apps/readest-app/src/app/reader/page.tsx @@ -6,6 +6,8 @@ import { useTranslation } from '@/hooks/useTranslation'; import { useAppUrlIngress } from '@/hooks/useAppUrlIngress'; import { useOpenWithBooks } from '@/hooks/useOpenWithBooks'; import { useOpenAnnotationLink } from '@/hooks/useOpenAnnotationLink'; +import { useOpenBookLink } from '@/hooks/useOpenBookLink'; +import { useReadingWidget } from '@/hooks/useReadingWidget'; import { useOpenShareLink } from '@/hooks/useOpenShareLink'; import { useClipUrlIngress } from '@/hooks/useClipUrlIngress'; import { useSettingsStore } from '@/store/settingsStore'; @@ -22,6 +24,8 @@ export default function Page() { useAppUrlIngress(); useOpenWithBooks(); useOpenAnnotationLink(); + useOpenBookLink(); + useReadingWidget(); useOpenShareLink(); useClipUrlIngress(); diff --git a/apps/readest-app/src/hooks/useOpenBookLink.ts b/apps/readest-app/src/hooks/useOpenBookLink.ts new file mode 100644 index 00000000..ffe60a69 --- /dev/null +++ b/apps/readest-app/src/hooks/useOpenBookLink.ts @@ -0,0 +1,106 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { getCurrent } from '@tauri-apps/plugin-deep-link'; +import { useEnv } from '@/context/EnvContext'; +import { useLibraryStore } from '@/store/libraryStore'; +import { isTauriAppPlatform } from '@/services/environment'; +import { navigateToReader } from '@/utils/nav'; +import { eventDispatcher } from '@/utils/event'; +import { parseBookDeepLink } from '@/utils/deeplink'; +import { useTranslation } from './useTranslation'; + +// Module-scoped: survives hook remounts (library <-> reader). getCurrent() +// keeps returning the launch URL for the session, so without this guard every +// remount would re-read the cold-start URL. +let coldStartConsumed = false; + +/** + * Receive `readest://book/{hash}` deep links (home-screen widget taps) and open + * the book in the reader. Subscribes to the shared 'app-incoming-url' event for + * live taps and reads getCurrent() once for cold start, deferring until the + * library has hydrated. + */ +export function useOpenBookLink() { + const _ = useTranslation(); + const router = useRouter(); + const { appService } = useEnv(); + const getBookByHash = useLibraryStore((s) => s.getBookByHash); + const libraryLoaded = useLibraryStore((s) => s.libraryLoaded); + const pending = useRef(null); + + const resolveAndNavigate = useCallback( + (bookHash: string) => { + const book = getBookByHash(bookHash); + if (!book) { + eventDispatcher.dispatch('toast', { + type: 'warning', + message: _('Book not in your library'), + timeout: 2500, + }); + return; + } + // If a reader is already mounted, switch in place via useBooksManager: it + // focuses the book if it is already open (checked against the live + // bookKeys) or replaces the open book(s) with it otherwise. A plain + // navigateToReader does not re-init an already-mounted reader. + if (window.location.pathname.startsWith('/reader')) { + eventDispatcher.dispatch('open-book-in-reader', { bookHash }); + return; + } + + // No reader mounted (library / cold start) - navigate fresh. + navigateToReader(router, [bookHash]); + }, + [_, getBookByHash, router], + ); + + useEffect(() => { + if (!isTauriAppPlatform() || !appService) return; + + const handle = (url: string, coldStart = false) => { + const parsed = parseBookDeepLink(url); + if (!parsed) return; + // Dedupe ONLY the cold-start path. The OS persists the launch deep link + // and re-delivers it via getCurrent() on every reader reload, which would + // re-open the book in a loop. Live taps (app-incoming-url) are genuine + // user actions and must always be processed. sessionStorage survives + // reloads (module state does not). + if (coldStart) { + try { + if (sessionStorage.getItem('consumedColdStartBookUrl') === url) return; + sessionStorage.setItem('consumedColdStartBookUrl', url); + } catch { + // sessionStorage unavailable - proceed. + } + } + if (!useLibraryStore.getState().libraryLoaded) { + pending.current = parsed.bookHash; + return; + } + resolveAndNavigate(parsed.bookHash); + }; + + if (!coldStartConsumed) { + coldStartConsumed = true; + getCurrent() + .then((urls) => urls?.forEach((u) => handle(u, true))) + .catch(() => {}); + } + + const onIncoming = (event: CustomEvent) => { + const { urls } = event.detail as { urls: string[] }; + urls.forEach((u) => handle(u)); + }; + eventDispatcher.on('app-incoming-url', onIncoming); + return () => { + eventDispatcher.off('app-incoming-url', onIncoming); + }; + }, [appService, resolveAndNavigate]); + + useEffect(() => { + if (!libraryLoaded || !pending.current) return; + const bookHash = pending.current; + pending.current = null; + resolveAndNavigate(bookHash); + }, [libraryLoaded, resolveAndNavigate]); +} diff --git a/apps/readest-app/src/hooks/useReadingWidget.ts b/apps/readest-app/src/hooks/useReadingWidget.ts new file mode 100644 index 00000000..6ad96476 --- /dev/null +++ b/apps/readest-app/src/hooks/useReadingWidget.ts @@ -0,0 +1,98 @@ +import { useEffect, useRef } from 'react'; +import { useEnv } from '@/context/EnvContext'; +import { useLibraryStore } from '@/store/libraryStore'; +import { refreshReadingWidget } from '@/services/widget/readingWidget'; +import { debounce } from '@/utils/debounce'; +import { eventDispatcher } from '@/utils/event'; +import { useTranslation } from './useTranslation'; + +/** + * Publish the home-screen reading-widget snapshot. The widget is only visible + * while the app is backgrounded, so we publish (1) once the library is loaded, + * (2) whenever the app goes to the background, (3) immediately on a TTS + * playback-state change (so controls appear/disappear), and (4) throttled on + * TTS position advances so the progress percent stays live while speaking. + * Mounted on both the library and reader pages. + */ +export function useReadingWidget() { + const _ = useTranslation(); + const { appService } = useEnv(); + const libraryLoaded = useLibraryStore((s) => s.libraryLoaded); + const ttsRef = useRef<{ active: boolean; playing: boolean }>({ + active: false, + playing: false, + }); + + useEffect(() => { + if (!appService?.isMobileApp) return; + const labels = { + // The widget intentionally shows no section header (minimal UI), so the + // section title is left empty. + sectionTitle: '', + emptyTitle: _('Your books will appear here'), + }; + + const publishNow = () => { + const tts = ttsRef.current; + void refreshReadingWidget( + appService, + labels, + tts.active ? { active: true, playing: tts.playing } : undefined, + ); + }; + + const publish = debounce(publishNow, 500); + // Leading interval throttle for TTS position. `tts-position` fires + // continuously while speaking, so a trailing debounce would perpetually + // reset its timer and never fire; and a pending setTimeout is unreliable + // once the app is backgrounded (the OS throttles background timers). + // Publish immediately, then at most once per interval, synchronously inside + // the event handler. + const TTS_POSITION_PUBLISH_INTERVAL = 5000; + let lastPositionPublishAt = 0; + + if (libraryLoaded) publish(); + + const onVisibility = () => { + if (document.visibilityState === 'hidden') { + // Flush now: the WebView may be suspended before a debounced timer + // fires, and backgrounding is exactly when the widget needs the latest + // reading progress. + publish(); + publish.flush(); + } + }; + + const onPlaybackState = (event: CustomEvent) => { + const detail = event.detail as { bookKey: string; state: 'playing' | 'paused' | 'stopped' }; + ttsRef.current = { + active: detail.state !== 'stopped', + playing: detail.state === 'playing', + }; + // Publish immediately so controls appear/disappear and the play/pause + // icon flips without waiting for the next debounce cycle. + publishNow(); + }; + + const onPosition = () => { + // Re-read the app-level book progress (the same value as the reader's + // progress bar) and re-publish. Leading throttle: publish immediately, + // then at most once per interval, synchronously so it still fires while + // the app is backgrounded. + const now = Date.now(); + if (now - lastPositionPublishAt < TTS_POSITION_PUBLISH_INTERVAL) return; + lastPositionPublishAt = now; + publishNow(); + }; + + document.addEventListener('visibilitychange', onVisibility); + eventDispatcher.on('tts-playback-state', onPlaybackState); + eventDispatcher.on('tts-position', onPosition); + return () => { + document.removeEventListener('visibilitychange', onVisibility); + eventDispatcher.off('tts-playback-state', onPlaybackState); + eventDispatcher.off('tts-position', onPosition); + publish.cancel(); + }; + }, [appService, libraryLoaded, _]); +} diff --git a/apps/readest-app/src/services/widget/readingWidget.ts b/apps/readest-app/src/services/widget/readingWidget.ts new file mode 100644 index 00000000..343c8f33 --- /dev/null +++ b/apps/readest-app/src/services/widget/readingWidget.ts @@ -0,0 +1,84 @@ +import type { Book } from '@/types/book'; +import type { AppService } from '@/types/system'; +import { useLibraryStore } from '@/store/libraryStore'; +import { getCoverFilename } from '@/utils/book'; +import { updateReadingWidget } from '@/utils/bridge'; +import type { ReadingWidgetTts } from '@/utils/bridge'; + +export interface ReadingWidgetBook { + hash: string; + title: string; + author: string; + percent: number; + coverPath: string; +} + +export interface ReadingWidgetPayload { + books: ReadingWidgetBook[]; + sectionTitle: string; + emptyTitle: string; + tts?: ReadingWidgetTts; +} + +const EXCLUDED_STATUSES = new Set(['finished', 'abandoned']); + +export const computeReadingPercent = (book: Book): number => { + const progress = book.progress; + if (!progress) return 0; + const [current, total] = progress; + if (!total || total <= 0) return 0; + return Math.min(100, Math.max(0, Math.round((current / total) * 100))); +}; + +export const selectReadingWidgetBooks = (library: Book[], limit = 3): Book[] => + library + .filter((b) => !b.deletedAt && !EXCLUDED_STATUSES.has(b.readingStatus ?? 'unread')) + .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)) + .slice(0, limit); + +export interface ReadingWidgetLabels { + sectionTitle: string; + emptyTitle: string; +} + +export const buildReadingWidgetPayload = async ( + books: Book[], + appService: AppService, + labels: ReadingWidgetLabels, + tts?: ReadingWidgetTts, +): Promise => { + // resolveFilePath('', 'Books') returns the absolute Books dir (no trailing + // slash) by delegating to fs.getPrefix internally. Both platforms use `/`, + // so plain string concatenation is correct and keeps the builder + // unit-testable without the Tauri path plugin. + const booksDir = (await appService.resolveFilePath('', 'Books')).replace(/\/+$/, ''); + const widgetBooks: ReadingWidgetBook[] = books.map((book) => ({ + hash: book.hash, + title: book.title ?? '', + author: book.author ?? '', + percent: computeReadingPercent(book), + coverPath: `${booksDir}/${getCoverFilename(book)}`, + })); + return { + books: widgetBooks, + sectionTitle: labels.sectionTitle, + emptyTitle: labels.emptyTitle, + ...(tts ? { tts } : {}), + }; +}; + +export const refreshReadingWidget = async ( + appService: AppService, + labels: ReadingWidgetLabels, + tts?: ReadingWidgetTts, +): Promise => { + if (!appService.isMobileApp) return; + const library = useLibraryStore.getState().library; + const selected = selectReadingWidgetBooks(library); + const payload = await buildReadingWidgetPayload(selected, appService, labels, tts); + try { + await updateReadingWidget(payload); + } catch (err) { + console.warn('Failed to update reading widget', err); + } +}; diff --git a/apps/readest-app/src/utils/bridge.ts b/apps/readest-app/src/utils/bridge.ts index 90c3840b..c77e72fc 100644 --- a/apps/readest-app/src/utils/bridge.ts +++ b/apps/readest-app/src/utils/bridge.ts @@ -341,6 +341,32 @@ export async function clearSecureItem(request: GetSecureItemRequest): Promise('plugin:native-bridge|clear_secure_item', { payload: request }); } +// ── Reading widget ──────────────────────────────────────────────────────── + +export interface ReadingWidgetBookPayload { + hash: string; + title: string; + author: string; + percent: number; + coverPath: string; +} + +export interface ReadingWidgetTts { + active: boolean; + playing: boolean; +} + +export interface UpdateReadingWidgetRequest { + books: ReadingWidgetBookPayload[]; + sectionTitle: string; + emptyTitle: string; + tts?: ReadingWidgetTts; +} + +export async function updateReadingWidget(request: UpdateReadingWidgetRequest): Promise { + await invoke('plugin:native-bridge|update_reading_widget', { payload: request }); +} + // ── Nightly updater (main-app commands, no native-bridge prefix) ───────── // `verify_update_signature` gates the custom install flows (portable / // AppImage / Android); `install_nightly_update` drives the Tauri updater for diff --git a/apps/readest-app/src/utils/deeplink.ts b/apps/readest-app/src/utils/deeplink.ts index aa64bdaf..df24bfaa 100644 --- a/apps/readest-app/src/utils/deeplink.ts +++ b/apps/readest-app/src/utils/deeplink.ts @@ -90,3 +90,38 @@ export const parseAnnotationDeepLink = (url: string): AnnotationDeepLink | null return null; }; + +/** + * Parse an incoming readest:// or https://web.readest.com book-open URL. + * Matches only the bare form `book/{hash}` (the widget tap target); the + * 4-segment annotation form `book/{hash}/annotation/{id}` is handled by + * parseAnnotationDeepLink and must NOT match here. + */ +export const parseBookDeepLink = (url: string): { bookHash: string } | null => { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + + const isCustomScheme = parsed.protocol === 'readest:'; + const isWebHost = + (parsed.protocol === 'https:' || parsed.protocol === 'http:') && + parsed.host === 'web.readest.com'; + if (!isCustomScheme && !isWebHost) return null; + + const segments: string[] = isCustomScheme + ? [parsed.host, ...parsed.pathname.split('/')].filter(Boolean) + : parsed.pathname.split('/').filter(Boolean); + + if (isWebHost) { + if (segments[0] !== 'o') return null; + segments.shift(); + } + + if (segments.length === 2 && segments[0] === 'book' && segments[1]) { + return { bookHash: segments[1] }; + } + return null; +};