mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-21 03:31:23 +08:00
472 lines
19 KiB
Swift
472 lines
19 KiB
Swift
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)
|
|
}
|
|
}
|