feat: improve epub reader controls and annotations
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user