Add a resizable home-screen widget on iOS and Android showing recent
in-progress books with cover, reading progress, and tap-to-open.
- One responsive widget: Android resizable 1x1 to 4x3 (one book per
column, up to 3); iOS Small/Medium/Large families. Covers are cropped,
rounded, with a percent badge and a progress bar (baked into the bitmap
on Android, SwiftUI overlays on iOS).
- TTS controls (previous, play-pause, next) appear in 2+ row sizes when
TTS is active, wired to the existing media session. Reading progress
stays live during background TTS via a fraction computed from the baked
offline locations.
- Publishes a snapshot plus downsized cover thumbnails to the iOS App
Group and Android SharedPreferences through a new update_reading_widget
native-bridge command; refresh is debounced and driven by library and
progress changes, TTS, and app backgrounding.
- Tapping a cover opens readest://book/{hash}, switching the reader in
place when one is already open.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+1
@@ -7895,6 +7895,7 @@ dependencies = [
|
||||
"keyring-core",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key><string>Readest</string>
|
||||
<key>CFBundleIdentifier</key><string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundlePackageType</key><string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key><string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.bilingify.readest</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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<ReadingEntry>) -> 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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
+10
@@ -21,5 +21,15 @@
|
||||
<receiver
|
||||
android:name="com.readest.native_bridge.LookupChoiceReceiver"
|
||||
android:exported="false" />
|
||||
<receiver
|
||||
android:name="com.readest.native_bridge.ReadingWidgetProvider"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/widget_reading_info" />
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
+62
@@ -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<UpdateReadingWidgetBookArgs> = 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
|
||||
|
||||
+117
@@ -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)
|
||||
}
|
||||
}
|
||||
+147
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"
|
||||
android:tint="?android:attr/textColorPrimary">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M6,19h4V5H6v14zm8,-14v14h4V5h-4z" />
|
||||
</vector>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"
|
||||
android:tint="?android:attr/textColorPrimary">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M8,5v14l11,-7z" />
|
||||
</vector>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"
|
||||
android:tint="?android:attr/textColorPrimary">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M6,18l8.5,-6L6,6v12zM16,6v12h2V6h-2z" />
|
||||
</vector>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"
|
||||
android:tint="?android:attr/textColorPrimary">
|
||||
<path android:fillColor="#FFFFFFFF" android:pathData="M6,6h2v12H6zm3.5,6l8.5,6V6z" />
|
||||
</vector>
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="?android:attr/colorBackground" />
|
||||
<corners android:radius="20dp" />
|
||||
</shape>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent" android:layout_height="match_parent"
|
||||
android:orientation="vertical" android:background="@drawable/widget_card_bg"
|
||||
android:padding="6dp">
|
||||
<!-- Empty FrameLayout used as a spacer. RemoteViews does not allow the base
|
||||
<View> type, so a whitelisted container is used instead. -->
|
||||
<FrameLayout android:id="@+id/top_spacer"
|
||||
android:layout_width="match_parent" android:layout_height="8dp"
|
||||
android:visibility="gone" />
|
||||
<LinearLayout android:id="@+id/row"
|
||||
android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"
|
||||
android:orientation="horizontal" android:baselineAligned="false">
|
||||
<ImageView android:id="@+id/cover0"
|
||||
android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp" android:scaleType="fitCenter"
|
||||
android:adjustViewBounds="true" android:contentDescription="@null" />
|
||||
<ImageView android:id="@+id/cover1"
|
||||
android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp" android:scaleType="fitCenter"
|
||||
android:adjustViewBounds="true" android:contentDescription="@null" />
|
||||
<ImageView android:id="@+id/cover2"
|
||||
android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp" android:scaleType="fitCenter"
|
||||
android:adjustViewBounds="true" android:contentDescription="@null" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:id="@+id/tts_bar"
|
||||
android:layout_width="match_parent" android:layout_height="wrap_content"
|
||||
android:orientation="horizontal" android:gravity="center"
|
||||
android:layout_marginTop="6dp" android:visibility="gone">
|
||||
<ImageView android:id="@+id/btn_prev"
|
||||
android:layout_width="40dp" android:layout_height="40dp" android:padding="8dp"
|
||||
android:src="@drawable/ic_widget_skip_previous" android:contentDescription="@null" />
|
||||
<ImageView android:id="@+id/btn_play_pause"
|
||||
android:layout_width="48dp" android:layout_height="48dp" android:padding="8dp"
|
||||
android:layout_marginHorizontal="20dp"
|
||||
android:src="@drawable/ic_widget_play" android:contentDescription="@null" />
|
||||
<ImageView android:id="@+id/btn_next"
|
||||
android:layout_width="40dp" android:layout_height="40dp" android:padding="8dp"
|
||||
android:src="@drawable/ic_widget_skip_next" android:contentDescription="@null" />
|
||||
</LinearLayout>
|
||||
<TextView android:id="@+id/empty"
|
||||
android:layout_width="match_parent" android:layout_height="match_parent"
|
||||
android:gravity="center" android:visibility="gone" android:textSize="12sp"
|
||||
android:textColor="?android:attr/textColorSecondary" />
|
||||
</LinearLayout>
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp" android:minHeight="110dp"
|
||||
android:minResizeWidth="40dp" android:minResizeHeight="40dp"
|
||||
android:maxResizeWidth="250dp" android:maxResizeHeight="180dp"
|
||||
android:targetCellWidth="4" android:targetCellHeight="2"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:updatePeriodMillis="0" android:widgetCategory="home_screen"
|
||||
android:initialLayout="@layout/widget_reading" />
|
||||
@@ -38,6 +38,7 @@ const COMMANDS: &[&str] = &[
|
||||
"get_secure_item",
|
||||
"clear_secure_item",
|
||||
"refresh_eink_screen",
|
||||
"update_reading_widget",
|
||||
];
|
||||
|
||||
fn main() {
|
||||
|
||||
+33
@@ -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()
|
||||
|
||||
+61
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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"]
|
||||
+27
@@ -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.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-update-reading-widget`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the update_reading_widget command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-update-reading-widget`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the update_reading_widget command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-use-background-audio`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -43,4 +43,5 @@ permissions = [
|
||||
"allow-get-secure-item",
|
||||
"allow-clear-secure-item",
|
||||
"allow-refresh-eink-screen",
|
||||
"allow-update-reading-widget",
|
||||
]
|
||||
|
||||
+14
-2
@@ -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`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -280,3 +280,11 @@ pub(crate) async fn refresh_eink_screen<R: Runtime>(
|
||||
) -> Result<RefreshEinkScreenResponse> {
|
||||
app.native_bridge().refresh_eink_screen()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn update_reading_widget<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: UpdateReadingWidgetRequest,
|
||||
) -> Result<()> {
|
||||
app.native_bridge().update_reading_widget(payload)
|
||||
}
|
||||
|
||||
@@ -351,6 +351,14 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn refresh_eink_screen(&self) -> crate::Result<RefreshEinkScreenResponse> {
|
||||
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";
|
||||
|
||||
@@ -89,6 +89,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
commands::get_secure_item,
|
||||
commands::clear_secure_item,
|
||||
commands::refresh_eink_screen,
|
||||
commands::update_reading_widget,
|
||||
])
|
||||
.setup(|app, api| {
|
||||
#[cfg(mobile)]
|
||||
|
||||
@@ -353,3 +353,11 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn update_reading_widget(&self, payload: UpdateReadingWidgetRequest) -> crate::Result<()> {
|
||||
self.0
|
||||
.run_mobile_plugin("update_reading_widget", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,3 +405,30 @@ pub struct RefreshEinkScreenResponse {
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[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<ReadingWidgetBook>,
|
||||
pub section_title: String,
|
||||
pub empty_title: String,
|
||||
#[serde(default)]
|
||||
pub tts: Option<ReadingWidgetTts>,
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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>): 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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=<hash>)
|
||||
// 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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<string | null>(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]);
|
||||
}
|
||||
@@ -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, _]);
|
||||
}
|
||||
@@ -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<ReadingWidgetPayload> => {
|
||||
// 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<void> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -341,6 +341,32 @@ export async function clearSecureItem(request: GetSecureItemRequest): Promise<Se
|
||||
return invoke<SecureItemResponse>('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<void> {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user