feat: improve epub reader controls and annotations

This commit is contained in:
shen
2026-07-12 12:23:10 +08:00
parent 063a493b18
commit 8ccb7157b2
36 changed files with 2959 additions and 2160 deletions
@@ -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() }
}
}