Files
legado-ios/App/Features/TTS/HttpTTSManagementView.swift
T

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