568 lines
22 KiB
Swift
568 lines
22 KiB
Swift
|
||
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() }
|
||
}
|
||
}
|