Files
readest/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/ReadingWidgetWriter.swift
T
Huang Xin 7da41a65ad feat(widget): add mobile home-screen reading widgets (#1602) (#4842)
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>
2026-06-28 18:03:16 +02:00

62 lines
2.1 KiB
Swift

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