mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-23 20:31:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
final class HttpTTSLibraryModel: ObservableObject {
|
||||
@Published private(set) var engines: [HttpTTS] = []
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private func repository() throws -> SQLiteConfigurationRepository {
|
||||
SQLiteConfigurationRepository(database: try LegadoDatabase(url: LegadoDatabase.defaultURL()))
|
||||
}
|
||||
|
||||
func load() async {
|
||||
do {
|
||||
var values = try await repository().loadHttpTTS()
|
||||
if values.isEmpty { values = try bundledDefaults() }
|
||||
engines = sorted(values)
|
||||
if !values.isEmpty { try await repository().saveHttpTTS(engines) }
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func save(_ value: HttpTTS) async {
|
||||
var value = value
|
||||
value.lastUpdateTime = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
var values = engines.filter { $0.id != value.id }
|
||||
values.append(value)
|
||||
await persist(values)
|
||||
}
|
||||
|
||||
func delete(at offsets: IndexSet) async {
|
||||
var values = engines
|
||||
values.remove(atOffsets: offsets)
|
||||
await persist(values)
|
||||
}
|
||||
|
||||
func importData(_ data: Data) async {
|
||||
do {
|
||||
let decoder = JSONDecoder()
|
||||
let imported: [HttpTTS]
|
||||
if let array = try? decoder.decode([HttpTTS].self, from: data) {
|
||||
imported = array
|
||||
} else {
|
||||
imported = [try decoder.decode(HttpTTS.self, from: data)]
|
||||
}
|
||||
var byID = Dictionary(uniqueKeysWithValues: engines.map { ($0.id, $0) })
|
||||
for value in imported { byID[value.id] = value }
|
||||
await persist(Array(byID.values))
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func restoreDefaults() async {
|
||||
do {
|
||||
let defaults = try bundledDefaults()
|
||||
var byID = Dictionary(uniqueKeysWithValues: engines.filter { $0.id >= 0 }.map { ($0.id, $0) })
|
||||
for value in defaults { byID[value.id] = value }
|
||||
await persist(Array(byID.values))
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func exportData() throws -> Data {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||
return try encoder.encode(engines)
|
||||
}
|
||||
|
||||
private func bundledDefaults() throws -> [HttpTTS] {
|
||||
guard let url = Bundle.main.url(forResource: "httpTTS", withExtension: "json") else {
|
||||
throw CocoaError(.fileNoSuchFile)
|
||||
}
|
||||
return try JSONDecoder().decode([HttpTTS].self, from: Data(contentsOf: url))
|
||||
}
|
||||
|
||||
private func persist(_ values: [HttpTTS]) async {
|
||||
do {
|
||||
let values = sorted(values)
|
||||
try await repository().saveHttpTTS(values)
|
||||
engines = values
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func sorted(_ values: [HttpTTS]) -> [HttpTTS] {
|
||||
values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
|
||||
struct HttpTTSManagementView: View {
|
||||
@StateObject private var model = HttpTTSLibraryModel()
|
||||
@State private var editing: HttpTTS?
|
||||
@State private var importsFile = false
|
||||
@State private var exportsFile = false
|
||||
@State private var exportDocument = HttpTTSJSONDocument(data: Data("[]".utf8))
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(model.engines) { engine in
|
||||
Button {
|
||||
editing = engine
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(engine.name).foregroundStyle(.primary)
|
||||
Text(engine.url).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in Task { await model.delete(at: offsets) } }
|
||||
}
|
||||
.overlay {
|
||||
if model.engines.isEmpty {
|
||||
ReadingUnavailableView("没有在线朗读引擎", systemImage: "waveform", description: Text("可恢复内置引擎或导入 HTTP TTS JSON。"))
|
||||
}
|
||||
}
|
||||
.navigationTitle("在线朗读引擎")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button("新建", systemImage: "plus") { editing = HttpTTS() }
|
||||
Menu("更多", systemImage: "ellipsis.circle") {
|
||||
Button("导入", systemImage: "square.and.arrow.down") { importsFile = true }
|
||||
Button("导出", systemImage: "square.and.arrow.up") {
|
||||
do {
|
||||
exportDocument = .init(data: try model.exportData())
|
||||
exportsFile = true
|
||||
} catch { model.errorMessage = error.localizedDescription }
|
||||
}
|
||||
Button("恢复内置引擎", systemImage: "arrow.counterclockwise") {
|
||||
Task { await model.restoreDefaults() }
|
||||
}
|
||||
Button("清除朗读缓存", systemImage: "trash") {
|
||||
Task { await HttpTTSAudioCache.shared.clear() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await model.load() }
|
||||
.sheet(item: $editing) { engine in
|
||||
ReadingNavigationStack {
|
||||
HttpTTSEditorView(engine: engine) { value in
|
||||
Task { await model.save(value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.fileImporter(isPresented: $importsFile, allowedContentTypes: [.json, .data]) { result in
|
||||
do {
|
||||
let url = try result.get()
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
|
||||
let data = try Data(contentsOf: url)
|
||||
Task { await model.importData(data) }
|
||||
} catch { model.errorMessage = error.localizedDescription }
|
||||
}
|
||||
.fileExporter(
|
||||
isPresented: $exportsFile,
|
||||
document: exportDocument,
|
||||
contentType: .json,
|
||||
defaultFilename: "httpTTS"
|
||||
) { result in
|
||||
if case .failure(let error) = result { model.errorMessage = error.localizedDescription }
|
||||
}
|
||||
.alert("在线朗读引擎", isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } }
|
||||
)) {
|
||||
Button("确定", role: .cancel) { model.errorMessage = nil }
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HttpTTSEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var engine: HttpTTS
|
||||
let onSave: (HttpTTS) -> Void
|
||||
@StateObject private var speech = ReaderSpeechController()
|
||||
@State private var testText = "这是一段在线朗读引擎测试文本。"
|
||||
@State private var loginInfo: String
|
||||
@State private var showsLoginForm = false
|
||||
|
||||
init(engine: HttpTTS, onSave: @escaping (HttpTTS) -> Void) {
|
||||
_engine = State(initialValue: engine)
|
||||
self.onSave = onSave
|
||||
_loginInfo = State(initialValue: BookSourceRuntimeStore.shared.get(sourceID: engine.sourceKey, key: "loginInfo"))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("基本") {
|
||||
TextField("名称", text: $engine.name)
|
||||
TextEditor(text: $engine.url).frame(minHeight: 110)
|
||||
TextField("Content-Type 正则", text: optional(\.contentType))
|
||||
TextField("并发率", text: optional(\.concurrentRate))
|
||||
Toggle("启用 Cookie", isOn: optionalBool(\.enabledCookieJar))
|
||||
}
|
||||
Section("请求与脚本") {
|
||||
fieldLabel("Header")
|
||||
TextEditor(text: optional(\.header)).frame(minHeight: 80)
|
||||
fieldLabel("JS 库")
|
||||
TextEditor(text: optional(\.jsLib)).frame(minHeight: 100)
|
||||
fieldLabel("登录地址/脚本")
|
||||
TextEditor(text: optional(\.loginUrl)).frame(minHeight: 80)
|
||||
fieldLabel("登录 UI")
|
||||
TextEditor(text: optional(\.loginUi)).frame(minHeight: 80)
|
||||
fieldLabel("登录信息 JSON")
|
||||
TextEditor(text: $loginInfo).frame(minHeight: 80)
|
||||
if engine.loginUi?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||
Button("打开登录表单", systemImage: "person.badge.key") { showsLoginForm = true }
|
||||
}
|
||||
fieldLabel("登录检查 JS")
|
||||
TextEditor(text: optional(\.loginCheckJs)).frame(minHeight: 100)
|
||||
}
|
||||
Section("测试") {
|
||||
TextField("测试文本", text: $testText)
|
||||
Button(speech.isSpeaking ? "停止测试" : "播放测试", systemImage: speech.isSpeaking ? "stop.fill" : "play.fill") {
|
||||
speech.configure(followsSystemRate: false, speechRate: 5, readByPage: true, streamsHTTPAudio: true)
|
||||
speech.toggleHTTP(engine: engine, text: testText)
|
||||
}
|
||||
if let error = speech.errorMessage { Text(error).foregroundStyle(.red).font(.footnote) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(engine.name.isEmpty ? "新建在线朗读引擎" : engine.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("保存") {
|
||||
BookSourceRuntimeStore.shared.put(sourceID: engine.sourceKey, key: "loginInfo", value: loginInfo)
|
||||
onSave(engine)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(engine.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || engine.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showsLoginForm) {
|
||||
ReadingNavigationStack {
|
||||
HttpTTSLoginView(engine: engine) { value in loginInfo = value }
|
||||
}
|
||||
}
|
||||
.onDisappear { speech.stop() }
|
||||
}
|
||||
|
||||
private func fieldLabel(_ value: String) -> some View {
|
||||
Text(value).font(.subheadline.weight(.medium))
|
||||
}
|
||||
|
||||
private func optional(_ keyPath: WritableKeyPath<HttpTTS, String?>) -> Binding<String> {
|
||||
Binding(get: { engine[keyPath: keyPath] ?? "" }, set: { engine[keyPath: keyPath] = $0.isEmpty ? nil : $0 })
|
||||
}
|
||||
|
||||
private func optionalBool(_ keyPath: WritableKeyPath<HttpTTS, Bool?>) -> Binding<Bool> {
|
||||
Binding(get: { engine[keyPath: keyPath] == true }, set: { engine[keyPath: keyPath] = $0 })
|
||||
}
|
||||
}
|
||||
|
||||
private struct HttpTTSLoginRow: Decodable, Identifiable {
|
||||
var name = ""
|
||||
var type = "text"
|
||||
var action: String?
|
||||
var chars: [String?]?
|
||||
var `default`: String?
|
||||
var viewName: String?
|
||||
var id: String { "\(name)-\(type)" }
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case name, type, action, chars, `default`, viewName }
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||
type = try values.decodeIfPresent(String.self, forKey: .type) ?? "text"
|
||||
action = try values.decodeIfPresent(String.self, forKey: .action)
|
||||
chars = try values.decodeIfPresent([String?].self, forKey: .chars)
|
||||
`default` = try values.decodeIfPresent(String.self, forKey: .default)
|
||||
viewName = try values.decodeIfPresent(String.self, forKey: .viewName)
|
||||
}
|
||||
}
|
||||
|
||||
private struct HttpTTSLoginView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let engine: HttpTTS
|
||||
let onSave: (String) -> Void
|
||||
@State private var rows: [HttpTTSLoginRow] = []
|
||||
@State private var values: [String: String] = [:]
|
||||
@State private var errorMessage: String?
|
||||
@State private var isRunning = false
|
||||
@State private var pendingFieldAction: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
if rows.isEmpty, errorMessage == nil {
|
||||
ProgressView("正在生成登录表单…")
|
||||
}
|
||||
ForEach(rows) { row in
|
||||
switch row.type {
|
||||
case "password":
|
||||
SecureField(label(for: row), text: binding(for: row))
|
||||
.onChange(of: values[row.name]) { _ in scheduleFieldAction(row.action) }
|
||||
case "select":
|
||||
Picker(label(for: row), selection: binding(for: row)) {
|
||||
ForEach(options(for: row), id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
.onChange(of: values[row.name]) { _ in runAction(row.action) }
|
||||
case "toggle":
|
||||
Picker(label(for: row), selection: binding(for: row)) {
|
||||
ForEach(options(for: row), id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.onChange(of: values[row.name]) { _ in runAction(row.action) }
|
||||
case "button":
|
||||
Button(label(for: row)) { runAction(row.action) }
|
||||
default:
|
||||
TextField(label(for: row), text: binding(for: row))
|
||||
.onChange(of: values[row.name]) { _ in scheduleFieldAction(row.action) }
|
||||
}
|
||||
}
|
||||
if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.footnote) }
|
||||
}
|
||||
.navigationTitle("登录 · \(engine.name)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(isRunning ? "执行中…" : "登录") { saveAndLogin() }.disabled(isRunning)
|
||||
}
|
||||
}
|
||||
.task { loadRows() }
|
||||
.onDisappear { pendingFieldAction?.cancel() }
|
||||
}
|
||||
|
||||
private func loadRows() {
|
||||
do {
|
||||
values = decodeLoginInfo()
|
||||
let json = try renderedLoginUI()
|
||||
rows = try JSONDecoder().decode([HttpTTSLoginRow].self, from: Data(json.utf8))
|
||||
for row in rows where row.type != "button" && values[row.name] == nil {
|
||||
values[row.name] = row.default ?? options(for: row).first ?? ""
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func renderedLoginUI() throws -> String {
|
||||
guard var raw = engine.loginUi?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
return "[]"
|
||||
}
|
||||
let script: String?
|
||||
if raw.hasPrefix("@js:") {
|
||||
script = String(raw.dropFirst(4))
|
||||
} else if raw.hasPrefix("<js>"), raw.hasSuffix("</js>") {
|
||||
script = String(raw.dropFirst(4).dropLast(5))
|
||||
} else {
|
||||
script = nil
|
||||
}
|
||||
if let script {
|
||||
let result = try JavaScriptRuleEvaluator().evaluate(
|
||||
script: script,
|
||||
result: .object(values.mapValues(JSONValue.string)),
|
||||
executionContext: .init(source: engine.bookSourceAdapter)
|
||||
)
|
||||
raw = result.value.stringValue
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
private func decodeLoginInfo() -> [String: String] {
|
||||
let raw = BookSourceRuntimeStore.shared.get(sourceID: engine.sourceKey, key: "loginInfo")
|
||||
guard let data = raw.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
|
||||
return object.mapValues { String(describing: $0) }
|
||||
}
|
||||
|
||||
private func binding(for row: HttpTTSLoginRow) -> Binding<String> {
|
||||
Binding(get: { values[row.name] ?? row.default ?? "" }, set: { values[row.name] = $0 })
|
||||
}
|
||||
|
||||
private func options(for row: HttpTTSLoginRow) -> [String] {
|
||||
let options = row.chars?.compactMap { $0 } ?? []
|
||||
return options.isEmpty ? [row.default ?? ""] : options
|
||||
}
|
||||
|
||||
private func label(for row: HttpTTSLoginRow) -> String {
|
||||
guard let value = row.viewName, value.count >= 2,
|
||||
value.first == "'", value.last == "'" else { return row.name }
|
||||
return String(value.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
private func runAction(_ action: String?) {
|
||||
guard let action, !action.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
isRunning = true
|
||||
errorMessage = nil
|
||||
let engine = engine
|
||||
let values = values
|
||||
Task { @MainActor in
|
||||
defer { isRunning = false }
|
||||
do {
|
||||
_ = try await Task.detached {
|
||||
try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "\(engine.loginUrl ?? "")\n\(action)",
|
||||
result: .object(values.mapValues(JSONValue.string)),
|
||||
executionContext: .init(source: engine.bookSourceAdapter)
|
||||
)
|
||||
}.value
|
||||
if engine.loginUi?.hasPrefix("@js:") == true { loadRows() }
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleFieldAction(_ action: String?) {
|
||||
guard action?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { return }
|
||||
pendingFieldAction?.cancel()
|
||||
pendingFieldAction = Task { @MainActor in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 600_000_000)
|
||||
runAction(action)
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAndLogin() {
|
||||
isRunning = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: values, options: [.sortedKeys])
|
||||
let raw = String(decoding: data, as: UTF8.self)
|
||||
BookSourceRuntimeStore.shared.put(sourceID: engine.sourceKey, key: "loginInfo", value: raw)
|
||||
let engine = engine
|
||||
let values = values
|
||||
Task { @MainActor in
|
||||
do {
|
||||
if let login = engine.loginUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
|
||||
_ = try await Task.detached {
|
||||
try JavaScriptRuleEvaluator().evaluate(
|
||||
script: "\(login)\nif (typeof login === 'function') login.apply(this);",
|
||||
result: .object(values.mapValues(JSONValue.string)),
|
||||
executionContext: .init(source: engine.bookSourceAdapter)
|
||||
)
|
||||
}.value
|
||||
}
|
||||
onSave(raw)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HttpTTSJSONDocument: FileDocument {
|
||||
static var readableContentTypes: [UTType] { [.json] }
|
||||
var data: Data
|
||||
|
||||
init(data: Data) { self.data = data }
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
data = configuration.file.regularFileContents ?? Data()
|
||||
}
|
||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
||||
FileWrapper(regularFileWithContents: data)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user