ReadViewSDK/Sources/RDEpubReaderView/EPUBCore/RDEPUBWebView+Configuration.swift

568 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import UIKit
import WebKit
extension RDEPUBWebView {
func configureWebViewIfNeeded(publication: RDEPUBPublication) {
let parser = publication.parser
let publicationKey = parser.opfURL?.path ?? parser.extractionRootURL?.path ?? UUID().uuidString
if publicationKey == configuredPublicationKey, webView != nil {
return
}
teardownWebView()
let userContentController = WKUserContentController()
let messageHandlerProxy = RDEPUBWeakScriptMessageHandler(target: self)
RDEPUBJavaScriptBridge.messageNames.forEach {
userContentController.add(messageHandlerProxy, name: $0)
}
userContentController.addUserScript(
WKUserScript(
source: RDEPUBJavaScriptBridge.userScript,
injectionTime: .atDocumentEnd,
forMainFrameOnly: true
)
)
userContentController.addUserScript(
WKUserScript(
source: Self.readerPageMediaLifecycleUserScript,
injectionTime: .atDocumentStart,
forMainFrameOnly: false
)
)
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
configuration.userContentController = userContentController
// Interactive fixed-layout EPUBs commonly start narration from their
// document-load timeline. Allow those page-owned audio elements to play
// without requiring an extra tap, and keep playback inside the reader.
configuration.allowsInlineMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = []
let schemeHandler = RDEPUBResourceURLSchemeHandler(parser: parser)
configuration.setURLSchemeHandler(schemeHandler, forURLScheme: RDEPUBResourceURLSchemeHandler.scheme)
let webView = RDEPUBAnnotationWebView(frame: bounds, configuration: configuration)
webView.onSelectionAction = { [weak self] action in
guard let self else { return }
self.handleSelectionMenuAction(action)
}
// M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController
if #available(iOS 16.0, *) {
let interaction = UIEditMenuInteraction(delegate: webView)
webView.addInteraction(interaction)
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(_:)))
]
}
webView.navigationDelegate = self
// Web 59pt 21pt
// /
// fixedContentInset / CSS
webView.scrollView.contentInsetAdjustmentBehavior = .never
webView.scrollView.isScrollEnabled = false
webView.scrollView.showsHorizontalScrollIndicator = false
webView.scrollView.showsVerticalScrollIndicator = false
webView.scrollView.bounces = false
webView.scrollView.alwaysBounceHorizontal = false
webView.scrollView.alwaysBounceVertical = false
webView.scrollView.clipsToBounds = true
webView.scrollView.panGestureRecognizer.isEnabled = false
webView.scrollView.pinchGestureRecognizer?.isEnabled = false
webView.isOpaque = false
webView.backgroundColor = .clear
webView.clipsToBounds = true
if #available(iOS 16.4, *) {
webView.isInspectable = RDEPUBWebViewDebug.isInspectableEnabled
}
addSubview(webView)
webView.frame = bounds
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
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)")
}
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction) {
delegate?.epubWebView(self, didRequestSelectionAction: action)
clearWebSelection()
}
func clearWebSelection() {
webView?.evaluateJavaScript("window.getSelection && window.getSelection().removeAllRanges();")
}
func teardownWebView() {
cancelFixedLayoutReadyFallback()
RDEPUBReaderMediaPlaybackCoordinator.shared.remove(self)
if let webView {
RDEPUBWebViewDebug.log(debugScope, message: "teardown webView=\(RDEPUBWebViewDebug.webViewID(webView))")
}
// M-15: Clean up UIEditMenuInteraction on iOS 16+
if #available(iOS 16.0, *), let interaction = (webView as? RDEPUBAnnotationWebView)?._editMenuInteraction as? UIEditMenuInteraction {
webView?.removeInteraction(interaction)
}
webView?.navigationDelegate = nil
if let userContentController = webView?.configuration.userContentController {
RDEPUBJavaScriptBridge.messageNames.forEach {
userContentController.removeScriptMessageHandler(forName: $0)
}
}
(webView as? RDEPUBAnnotationWebView)?.onSelectionAction = nil
webView?.removeFromSuperview()
webView = nil
finishAllNativeMediaPlaybackRequests()
isWaitingForReaderPageMediaNavigation = false
pendingReaderPageMediaNavigation = nil
isReaderPageMediaActivationBlocked = false
schemeHandler = nil
configuredPublicationKey = nil
currentLoadSignature = nil
}
}
/// M-08: WKUserContentController script message handler
/// RDEPUBWebView
/// webView userContentController RDEPUBWebView webView
/// WebView handler
/// RDEPUBWebView deinit
private final class RDEPUBWeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
private weak var target: WKScriptMessageHandler?
init(target: WKScriptMessageHandler) {
self.target = target
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
target?.userContentController(userContentController, didReceive: message)
}
}
/// 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 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;
window.__rdReaderMediaLifecycleInstalled = true;
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;
};
const collectDocumentMedia = () => {
if (!document.querySelectorAll) return;
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) {
try {
media.currentTime = 0;
const result = nativePlay && nativePlay.call(media);
if (result && result.then) {
result.then(() => { media.__rdReaderReplayWhenVisible = false; }).catch(() => {});
} else {
media.__rdReaderReplayWhenVisible = false;
}
} catch (_) {}
}
} else {
if (media.__rdReaderWasPlayed || media.autoplay || (!media.paused && !media.ended)) {
media.__rdReaderReplayWhenVisible = true;
}
if (!media.paused) {
try { media.pause(); } catch (_) {}
}
}
});
trackedAudioContexts.forEach(updateAudioContext);
};
if (mediaPrototype && nativePlay) {
mediaPrototype.play = function() {
register(this);
this.__rdReaderWasPlayed = true;
if (!isActive) {
this.__rdReaderReplayWhenVisible = true;
try { this.pause(); } catch (_) {}
return Promise.resolve();
}
this.__rdReaderReplayWhenVisible = false;
return nativePlay.apply(this, arguments);
};
}
const NativeAudio = window.Audio;
if (typeof NativeAudio === "function") {
const ReaderAudio = function(source) {
const media = arguments.length > 0 ? new NativeAudio(source) : new NativeAudio();
return register(media);
};
ReaderAudio.prototype = NativeAudio.prototype;
try { Object.setPrototypeOf(ReaderAudio, NativeAudio); } catch (_) {}
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) 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);
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 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() }
}
}