feat: improve epub reader controls and annotations
This commit is contained in:
@@ -110,6 +110,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
|
||||
public var note: String?
|
||||
|
||||
/// True when this record was created directly as an annotation instead of
|
||||
/// adding a note to an existing underline.
|
||||
public var isAnnotationOnly: Bool
|
||||
|
||||
public var createdAt: Date
|
||||
|
||||
public init(
|
||||
@@ -118,9 +122,10 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
location: RDEPUBLocation,
|
||||
text: String,
|
||||
rangeInfo: String? = nil,
|
||||
style: RDEPUBHighlightStyle = .highlight,
|
||||
style: RDEPUBHighlightStyle = .underline,
|
||||
color: String = "#F8E16C",
|
||||
note: String? = nil,
|
||||
isAnnotationOnly: Bool = false,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
@@ -131,6 +136,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
self.style = style
|
||||
self.color = color
|
||||
self.note = note?.nilIfEmpty
|
||||
self.isAnnotationOnly = isAnnotationOnly
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
@@ -152,6 +158,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
case style
|
||||
case color
|
||||
case note
|
||||
case isAnnotationOnly
|
||||
case createdAt
|
||||
}
|
||||
|
||||
@@ -164,12 +171,13 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
rangeInfo = try container.decodeIfPresent(String.self, forKey: .rangeInfo)?.nilIfEmpty
|
||||
if let rawStyle = try container.decodeIfPresent(String.self, forKey: .style),
|
||||
let decodedStyle = RDEPUBHighlightStyle(rawValue: rawStyle) {
|
||||
style = decodedStyle
|
||||
style = decodedStyle == .highlight ? .underline : decodedStyle
|
||||
} else {
|
||||
style = .highlight
|
||||
style = .underline
|
||||
}
|
||||
color = try container.decodeIfPresent(String.self, forKey: .color) ?? "#F8E16C"
|
||||
note = try container.decodeIfPresent(String.self, forKey: .note)?.nilIfEmpty
|
||||
isAnnotationOnly = try container.decodeIfPresent(Bool.self, forKey: .isAnnotationOnly) ?? false
|
||||
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
|
||||
}
|
||||
|
||||
@@ -183,6 +191,7 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
try container.encode(style, forKey: .style)
|
||||
try container.encode(color, forKey: .color)
|
||||
try container.encodeIfPresent(note, forKey: .note)
|
||||
try container.encode(isAnnotationOnly, forKey: .isAnnotationOnly)
|
||||
try container.encode(createdAt, forKey: .createdAt)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,13 @@ public final class RDEPUBPublication {
|
||||
parser.readingProfile()
|
||||
}
|
||||
|
||||
/// Scripted fixed-layout publications own a complete interactive scene per
|
||||
/// spine item. Combining scenes in landscape can start multiple timelines
|
||||
/// and media tracks, so these publications remain single-page.
|
||||
public lazy var requiresSinglePagePresentation: Bool = {
|
||||
layout == .fixed && parser.hasInteractiveContent()
|
||||
}()
|
||||
|
||||
public var readingProgression: RDEPUBReadingProgression {
|
||||
metadata.readingProgression
|
||||
}
|
||||
@@ -68,4 +75,4 @@ public final class RDEPUBPublication {
|
||||
spreadEnabled: fixedLayoutSpreadEnabled(for: preferences, viewportSize: viewportSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,9 @@ extension RDEPUBWebView {
|
||||
webView._editMenuInteraction = interaction
|
||||
} else {
|
||||
UIMenuController.shared.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
||||
UIMenuItem(title: "复制", action: #selector(RDEPUBAnnotationWebView.rd_copy(_:))),
|
||||
UIMenuItem(title: "划线", action: #selector(RDEPUBAnnotationWebView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "注释", action: #selector(RDEPUBAnnotationWebView.rd_annotate(_:)))
|
||||
]
|
||||
}
|
||||
webView.navigationDelegate = self
|
||||
@@ -89,6 +89,11 @@ extension RDEPUBWebView {
|
||||
self.schemeHandler = schemeHandler
|
||||
self.webView = webView
|
||||
self.configuredPublicationKey = publicationKey
|
||||
appliedNativeMediaPlaybackSuspended = false
|
||||
isNativeMediaPlaybackOperationInFlight = false
|
||||
nativeMediaPlaybackOperationGeneration &+= 1
|
||||
nativeMediaPlaybackRequests.removeAll()
|
||||
setNativeReaderPageMediaPlaybackSuspended(true)
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "configured webView=\(RDEPUBWebViewDebug.webViewID(webView)) publicationKey=\(publicationKey)")
|
||||
}
|
||||
|
||||
@@ -103,6 +108,7 @@ extension RDEPUBWebView {
|
||||
|
||||
func teardownWebView() {
|
||||
cancelFixedLayoutReadyFallback()
|
||||
RDEPUBReaderMediaPlaybackCoordinator.shared.remove(self)
|
||||
if let webView {
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))")
|
||||
}
|
||||
@@ -119,6 +125,10 @@ extension RDEPUBWebView {
|
||||
(webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil
|
||||
webView?.removeFromSuperview()
|
||||
webView = nil
|
||||
finishAllNativeMediaPlaybackRequests()
|
||||
isWaitingForReaderPageMediaNavigation = false
|
||||
pendingReaderPageMediaNavigation = nil
|
||||
isReaderPageMediaActivationBlocked = false
|
||||
schemeHandler = nil
|
||||
configuredPublicationKey = nil
|
||||
currentLoadSignature = nil
|
||||
@@ -143,11 +153,101 @@ private final class RDEPUBWeakScriptMessageHandler: NSObject, WKScriptMessageHan
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes native media activation across page WebViews. WebKit suspension
|
||||
/// is asynchronous and separate WKWebView instances have no ordering contract;
|
||||
/// the incoming page therefore waits for the previous active page's suspension
|
||||
/// completion before it can be resumed.
|
||||
private final class RDEPUBReaderMediaPlaybackCoordinator {
|
||||
|
||||
static let shared = RDEPUBReaderMediaPlaybackCoordinator()
|
||||
|
||||
private weak var activePage: RDEPUBWebView?
|
||||
private weak var pendingPage: RDEPUBWebView?
|
||||
private var activationGeneration: UInt = 0
|
||||
|
||||
func setPlaybackSuspended(
|
||||
_ suspended: Bool,
|
||||
for page: RDEPUBWebView,
|
||||
completion: @escaping () -> Void = {}
|
||||
) {
|
||||
if suspended {
|
||||
suspend(page, completion: completion)
|
||||
} else {
|
||||
activate(page, completion: completion)
|
||||
}
|
||||
}
|
||||
|
||||
func remove(_ page: RDEPUBWebView) {
|
||||
if pendingPage === page {
|
||||
activationGeneration &+= 1
|
||||
}
|
||||
if activePage === page {
|
||||
activePage = nil
|
||||
}
|
||||
if pendingPage === page {
|
||||
pendingPage = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func suspend(_ page: RDEPUBWebView, completion: @escaping () -> Void) {
|
||||
if pendingPage === page || (activePage === page && pendingPage == nil) {
|
||||
activationGeneration &+= 1
|
||||
}
|
||||
if pendingPage === page {
|
||||
pendingPage = nil
|
||||
}
|
||||
let suspensionGeneration = activationGeneration
|
||||
|
||||
page.applyNativeMediaPlaybackSuspended(true) { [weak self, weak page] in
|
||||
guard let self, let page else { return }
|
||||
if self.activationGeneration == suspensionGeneration,
|
||||
self.activePage === page {
|
||||
self.activePage = nil
|
||||
}
|
||||
completion()
|
||||
}
|
||||
}
|
||||
|
||||
private func activate(_ page: RDEPUBWebView, completion: @escaping () -> Void) {
|
||||
activationGeneration &+= 1
|
||||
let generation = activationGeneration
|
||||
pendingPage = page
|
||||
let outgoingPage = activePage
|
||||
|
||||
let resumeIncomingPage = { [weak self, weak page] in
|
||||
guard let self,
|
||||
let page,
|
||||
self.activationGeneration == generation,
|
||||
self.pendingPage === page else { return }
|
||||
self.pendingPage = nil
|
||||
self.activePage = page
|
||||
page.applyNativeMediaPlaybackSuspended(false) { [weak self, weak page] in
|
||||
guard let self, let page else { return }
|
||||
if self.activePage !== page {
|
||||
page.applyNativeMediaPlaybackSuspended(true, completion: {})
|
||||
} else {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let outgoingPage, outgoingPage !== page else {
|
||||
resumeIncomingPage()
|
||||
return
|
||||
}
|
||||
|
||||
outgoingPage.applyNativeMediaPlaybackSuspended(true) {
|
||||
resumeIncomingPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension RDEPUBWebView {
|
||||
|
||||
/// Installed in every frame before EPUB scripts run. Hidden pages start in
|
||||
/// a suspended state, including audio created with `new Audio()` that is
|
||||
/// not attached to the DOM (as used by Tumult Hype publications).
|
||||
/// a suspended state for both HTML media and Web Audio. Tumult Hype uses
|
||||
/// `AudioContext`/`AudioBufferSourceNode` for this publication, so handling
|
||||
/// only `<audio>` and `new Audio()` leaves narration running on cached pages.
|
||||
static let readerPageMediaLifecycleUserScript = #"""
|
||||
(() => {
|
||||
if (window.__rdReaderMediaLifecycleInstalled) return;
|
||||
@@ -155,10 +255,17 @@ extension RDEPUBWebView {
|
||||
|
||||
let isActive = false;
|
||||
const trackedMedia = new Set();
|
||||
const trackedAudioContexts = new Set();
|
||||
const lifecycleSuspendedAudioContexts = new Set();
|
||||
const command = "rd-reader-page-visibility";
|
||||
const frameReadyCommand = "rd-reader-frame-media-ready";
|
||||
const mediaPrototype = window.HTMLMediaElement && window.HTMLMediaElement.prototype;
|
||||
const nativePlay = mediaPrototype && mediaPrototype.play;
|
||||
|
||||
const ignoreRejection = result => {
|
||||
if (result && result.catch) result.catch(() => {});
|
||||
};
|
||||
|
||||
const register = media => {
|
||||
if (media) trackedMedia.add(media);
|
||||
return media;
|
||||
@@ -169,17 +276,47 @@ extension RDEPUBWebView {
|
||||
document.querySelectorAll("audio,video").forEach(register);
|
||||
};
|
||||
|
||||
const updateAudioContext = context => {
|
||||
if (!context || context.state === "closed") return;
|
||||
if (isActive) {
|
||||
if (!lifecycleSuspendedAudioContexts.has(context)) return;
|
||||
try {
|
||||
const result = context.resume();
|
||||
if (result && result.then) {
|
||||
result.then(() => lifecycleSuspendedAudioContexts.delete(context)).catch(() => {});
|
||||
} else {
|
||||
lifecycleSuspendedAudioContexts.delete(context);
|
||||
}
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (context.state !== "running" || lifecycleSuspendedAudioContexts.has(context)) return;
|
||||
lifecycleSuspendedAudioContexts.add(context);
|
||||
try { ignoreRejection(context.suspend()); } catch (_) {}
|
||||
};
|
||||
|
||||
const registerAudioContext = context => {
|
||||
if (context) {
|
||||
trackedAudioContexts.add(context);
|
||||
updateAudioContext(context);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const setActive = active => {
|
||||
isActive = !!active;
|
||||
collectDocumentMedia();
|
||||
trackedMedia.forEach(media => {
|
||||
if (isActive) {
|
||||
if (media.__rdReaderReplayWhenVisible) {
|
||||
media.__rdReaderReplayWhenVisible = false;
|
||||
try {
|
||||
media.currentTime = 0;
|
||||
const result = nativePlay && nativePlay.call(media);
|
||||
if (result && result.catch) result.catch(() => {});
|
||||
if (result && result.then) {
|
||||
result.then(() => { media.__rdReaderReplayWhenVisible = false; }).catch(() => {});
|
||||
} else {
|
||||
media.__rdReaderReplayWhenVisible = false;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
} else {
|
||||
@@ -191,6 +328,7 @@ extension RDEPUBWebView {
|
||||
}
|
||||
}
|
||||
});
|
||||
trackedAudioContexts.forEach(updateAudioContext);
|
||||
};
|
||||
|
||||
if (mediaPrototype && nativePlay) {
|
||||
@@ -218,34 +356,212 @@ extension RDEPUBWebView {
|
||||
window.Audio = ReaderAudio;
|
||||
}
|
||||
|
||||
const installAudioContext = name => {
|
||||
const NativeAudioContext = window[name];
|
||||
if (typeof NativeAudioContext !== "function") return;
|
||||
const ReaderAudioContext = function() {
|
||||
const context = Reflect.construct(
|
||||
NativeAudioContext,
|
||||
Array.prototype.slice.call(arguments),
|
||||
NativeAudioContext
|
||||
);
|
||||
return registerAudioContext(context);
|
||||
};
|
||||
ReaderAudioContext.prototype = NativeAudioContext.prototype;
|
||||
try { Object.setPrototypeOf(ReaderAudioContext, NativeAudioContext); } catch (_) {}
|
||||
try { window[name] = ReaderAudioContext; } catch (_) {}
|
||||
};
|
||||
installAudioContext("AudioContext");
|
||||
installAudioContext("webkitAudioContext");
|
||||
|
||||
const sendVisibility = target => {
|
||||
try {
|
||||
target.postMessage({ type: command, active: isActive }, "*");
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
const broadcastVisibility = () => {
|
||||
for (let index = 0; index < window.frames.length; index += 1) {
|
||||
sendVisibility(window.frames[index]);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", event => {
|
||||
const message = event.data;
|
||||
if (!message || message.type !== command) return;
|
||||
setActive(message.active);
|
||||
for (let index = 0; index < window.frames.length; index += 1) {
|
||||
try { window.frames[index].postMessage(message, "*"); } catch (_) {}
|
||||
if (!message) return;
|
||||
if (message.type === frameReadyCommand) {
|
||||
if (event.source) sendVisibility(event.source);
|
||||
return;
|
||||
}
|
||||
if (message.type !== command) return;
|
||||
setActive(message.active);
|
||||
broadcastVisibility();
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => setActive(isActive));
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
setActive(isActive);
|
||||
broadcastVisibility();
|
||||
});
|
||||
if (window.MutationObserver) {
|
||||
new MutationObserver(() => setActive(isActive)).observe(document, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
}
|
||||
if (window.parent !== window) {
|
||||
try { window.parent.postMessage({ type: frameReadyCommand }, "*"); } catch (_) {}
|
||||
}
|
||||
})();
|
||||
"""#
|
||||
|
||||
func setReaderPageVisible(_ isVisible: Bool) {
|
||||
let wasVisible = isReaderPageVisible
|
||||
isReaderPageVisible = isVisible
|
||||
|
||||
if !isVisible {
|
||||
if wasVisible,
|
||||
hasReaderPageBeenVisible,
|
||||
currentRenderRequest?.isFixedLayout == true {
|
||||
needsReaderPagePlaybackRestart = true
|
||||
}
|
||||
applyReaderPageMediaVisibility()
|
||||
return
|
||||
}
|
||||
|
||||
if !wasVisible,
|
||||
needsReaderPagePlaybackRestart,
|
||||
restartReaderPagePlayback() {
|
||||
return
|
||||
}
|
||||
|
||||
guard !isWaitingForReaderPageMediaNavigation else { return }
|
||||
applyReaderPageMediaVisibility()
|
||||
}
|
||||
|
||||
func readerPageMediaNavigationDidFinish(_ navigation: WKNavigation?) -> Bool {
|
||||
if isWaitingForReaderPageMediaNavigation,
|
||||
let pendingReaderPageMediaNavigation,
|
||||
let navigation,
|
||||
pendingReaderPageMediaNavigation !== navigation {
|
||||
return false
|
||||
}
|
||||
pendingReaderPageMediaNavigation = nil
|
||||
isReaderPageMediaActivationBlocked = false
|
||||
isWaitingForReaderPageMediaNavigation = false
|
||||
applyReaderPageMediaVisibility()
|
||||
return true
|
||||
}
|
||||
|
||||
func readerPageMediaNavigationDidFail(_ navigation: WKNavigation?) {
|
||||
if let pendingReaderPageMediaNavigation,
|
||||
let navigation,
|
||||
pendingReaderPageMediaNavigation !== navigation {
|
||||
return
|
||||
}
|
||||
pendingReaderPageMediaNavigation = nil
|
||||
isReaderPageMediaActivationBlocked = true
|
||||
isWaitingForReaderPageMediaNavigation = false
|
||||
// A failed fixed-layout reload may leave the outgoing Hype document
|
||||
// alive. Keep it suspended instead of reviving narration from the page
|
||||
// the user already left; the next successful load will activate again.
|
||||
setNativeReaderPageMediaPlaybackSuspended(true)
|
||||
webView?.evaluateJavaScript(
|
||||
"window.postMessage({type: 'rd-reader-page-visibility', active: false}, '*');",
|
||||
completionHandler: nil
|
||||
)
|
||||
}
|
||||
|
||||
func applyReaderPageMediaVisibility() {
|
||||
guard let webView else { return }
|
||||
let active = isReaderPageVisible ? "true" : "false"
|
||||
let script = "window.postMessage({type: 'rd-reader-page-visibility', active: \(active)}, '*');"
|
||||
webView.evaluateJavaScript(script, completionHandler: nil)
|
||||
let isVisible = isReaderPageVisible && !isReaderPageMediaActivationBlocked
|
||||
if !isVisible {
|
||||
setNativeReaderPageMediaPlaybackSuspended(true)
|
||||
webView.evaluateJavaScript(
|
||||
"window.postMessage({type: 'rd-reader-page-visibility', active: false}, '*');",
|
||||
completionHandler: nil
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
hasReaderPageBeenVisible = true
|
||||
setNativeReaderPageMediaPlaybackSuspended(false) { [weak self, weak webView] in
|
||||
guard let self,
|
||||
let webView,
|
||||
self.webView === webView,
|
||||
self.isReaderPageVisible,
|
||||
!self.isReaderPageMediaActivationBlocked,
|
||||
!self.isWaitingForReaderPageMediaNavigation else { return }
|
||||
webView.evaluateJavaScript(
|
||||
"window.postMessage({type: 'rd-reader-page-visibility', active: true}, '*');",
|
||||
completionHandler: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func restartReaderPagePlayback() -> Bool {
|
||||
guard let publication,
|
||||
let currentRenderRequest,
|
||||
currentRenderRequest.isFixedLayout else { return false }
|
||||
load(publication: publication, request: currentRenderRequest)
|
||||
return true
|
||||
}
|
||||
|
||||
func setNativeReaderPageMediaPlaybackSuspended(
|
||||
_ suspended: Bool,
|
||||
completion: @escaping () -> Void = {}
|
||||
) {
|
||||
RDEPUBReaderMediaPlaybackCoordinator.shared.setPlaybackSuspended(
|
||||
suspended,
|
||||
for: self,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
func applyNativeMediaPlaybackSuspended(_ suspended: Bool, completion: @escaping () -> Void) {
|
||||
nativeMediaPlaybackRequests.append((suspended, completion))
|
||||
processNextNativeMediaPlaybackRequest()
|
||||
}
|
||||
|
||||
private func processNextNativeMediaPlaybackRequest() {
|
||||
guard !isNativeMediaPlaybackOperationInFlight else { return }
|
||||
guard let webView else {
|
||||
finishAllNativeMediaPlaybackRequests()
|
||||
return
|
||||
}
|
||||
guard let request = nativeMediaPlaybackRequests.first else { return }
|
||||
|
||||
guard appliedNativeMediaPlaybackSuspended != request.suspended else {
|
||||
nativeMediaPlaybackRequests.removeFirst()
|
||||
request.completion()
|
||||
processNextNativeMediaPlaybackRequest()
|
||||
return
|
||||
}
|
||||
|
||||
let targetSuspended = request.suspended
|
||||
isNativeMediaPlaybackOperationInFlight = true
|
||||
nativeMediaPlaybackOperationGeneration &+= 1
|
||||
let operationGeneration = nativeMediaPlaybackOperationGeneration
|
||||
webView.setAllMediaPlaybackSuspended(targetSuspended) { [weak self, weak webView] in
|
||||
guard let self else { return }
|
||||
guard operationGeneration == self.nativeMediaPlaybackOperationGeneration,
|
||||
let webView,
|
||||
self.webView === webView else { return }
|
||||
|
||||
self.appliedNativeMediaPlaybackSuspended = targetSuspended
|
||||
self.isNativeMediaPlaybackOperationInFlight = false
|
||||
guard let completedRequest = self.nativeMediaPlaybackRequests.first else { return }
|
||||
self.nativeMediaPlaybackRequests.removeFirst()
|
||||
completedRequest.completion()
|
||||
self.processNextNativeMediaPlaybackRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private func finishAllNativeMediaPlaybackRequests() {
|
||||
let completions = nativeMediaPlaybackRequests.map(\.completion)
|
||||
nativeMediaPlaybackRequests.removeAll()
|
||||
nativeMediaPlaybackOperationGeneration &+= 1
|
||||
appliedNativeMediaPlaybackSuspended = false
|
||||
isNativeMediaPlaybackOperationInFlight = false
|
||||
completions.forEach { $0() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ extension RDEPUBWebView {
|
||||
|
||||
let html = RDEPUBFixedLayoutTemplate.html(for: request, publication: publication)
|
||||
RDEPUBWebViewDebug.log(debugScope, message: "load fixed spread resources=\(request.spread.resources.map(\.href).joined(separator: ",")) fit=\(request.fit.rawValue)")
|
||||
webView.loadHTMLString(
|
||||
pendingReaderPageMediaNavigation = webView.loadHTMLString(
|
||||
html,
|
||||
baseURL: URL(string: "\(RDEPUBResourceURLSchemeHandler.scheme)://\(RDEPUBResourceURLSchemeHandler.host)/")
|
||||
)
|
||||
|
||||
@@ -31,20 +31,33 @@ extension RDEPUBWebView: WKNavigationDelegate {
|
||||
|
||||
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
|
||||
applyReaderPageMediaVisibility()
|
||||
guard readerPageMediaNavigationDidFinish(navigation) else { return }
|
||||
applyPresentation()
|
||||
}
|
||||
|
||||
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "processTerminated", url: webView.url)
|
||||
// WebKit may not complete an in-flight media suspension after its
|
||||
// content process exits. Tearing down flushes the FIFO callbacks and
|
||||
// lets the coordinator activate another page without deadlocking.
|
||||
// Rebuild the page afterward so a cached/current page does not remain
|
||||
// permanently blank after WebKit recovers its content process.
|
||||
let publication = publication
|
||||
let renderRequest = currentRenderRequest
|
||||
teardownWebView()
|
||||
if let publication, let renderRequest {
|
||||
load(publication: publication, request: renderRequest)
|
||||
}
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFail", url: webView.url, error: error)
|
||||
readerPageMediaNavigationDidFail(navigation)
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFailProvisional", url: webView.url, error: error)
|
||||
readerPageMediaNavigationDidFail(navigation)
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
|
||||
@@ -74,9 +74,9 @@ extension RDEPUBAnnotationWebView: UIEditMenuInteractionDelegate {
|
||||
menuFor configuration: UIEditMenuConfiguration
|
||||
) -> UIMenu {
|
||||
UIMenu(children: [
|
||||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
UICommand(title: "复制", action: #selector(rd_copy(_:))),
|
||||
UICommand(title: "划线", action: #selector(rd_highlight(_:))),
|
||||
UICommand(title: "注释", action: #selector(rd_annotate(_:)))
|
||||
])
|
||||
}
|
||||
|
||||
@@ -138,6 +138,24 @@ public final class RDEPUBWebView: UIView {
|
||||
|
||||
var isReaderPageVisible = true
|
||||
|
||||
var hasReaderPageBeenVisible = false
|
||||
|
||||
var needsReaderPagePlaybackRestart = false
|
||||
|
||||
var appliedNativeMediaPlaybackSuspended = false
|
||||
|
||||
var isNativeMediaPlaybackOperationInFlight = false
|
||||
|
||||
var nativeMediaPlaybackOperationGeneration: UInt = 0
|
||||
|
||||
var nativeMediaPlaybackRequests: [(suspended: Bool, completion: () -> Void)] = []
|
||||
|
||||
var isWaitingForReaderPageMediaNavigation = false
|
||||
|
||||
var pendingReaderPageMediaNavigation: WKNavigation?
|
||||
|
||||
var isReaderPageMediaActivationBlocked = false
|
||||
|
||||
var pendingProgressionRequest = false
|
||||
|
||||
var fixedLayoutReadyWorkItem: DispatchWorkItem?
|
||||
@@ -189,6 +207,11 @@ public final class RDEPUBWebView: UIView {
|
||||
fixedSpread = nil
|
||||
isFixedLayout = false
|
||||
isReaderPageVisible = true
|
||||
hasReaderPageBeenVisible = false
|
||||
needsReaderPagePlaybackRestart = false
|
||||
isWaitingForReaderPageMediaNavigation = false
|
||||
pendingReaderPageMediaNavigation = nil
|
||||
isReaderPageMediaActivationBlocked = false
|
||||
pendingProgressionRequest = false
|
||||
didRenderCurrentRequest = false
|
||||
teardownWebView()
|
||||
@@ -207,6 +230,15 @@ public final class RDEPUBWebView: UIView {
|
||||
|
||||
let publicationKey = publication.parser.opfURL?.path ?? publication.parser.extractionRootURL?.path ?? ""
|
||||
let loadSignature = pageLoadSignature(publicationKey: publicationKey, request: request)
|
||||
if request.isFixedLayout {
|
||||
// A fixed-layout load replaces the whole Hype document. Keep WebKit
|
||||
// suspended until navigation finishes so the outgoing page cannot
|
||||
// overlap the new page's zero-second narration.
|
||||
hasReaderPageBeenVisible = false
|
||||
needsReaderPagePlaybackRestart = false
|
||||
isWaitingForReaderPageMediaNavigation = true
|
||||
setNativeReaderPageMediaPlaybackSuspended(true)
|
||||
}
|
||||
RDEPUBWebViewDebug.log(
|
||||
debugScope,
|
||||
message: "load request=\(request.isFixedLayout ? "fixed" : "reflowable") signature=\(loadSignature) webView=\(RDEPUBWebViewDebug.webViewID(webView))"
|
||||
|
||||
Reference in New Issue
Block a user