fix epub reader playback and trial navigation

This commit is contained in:
shen
2026-07-11 08:16:56 +08:00
parent d7fcda345d
commit 063a493b18
231 changed files with 435 additions and 36060 deletions
@@ -56,4 +56,4 @@ public struct RDEPUBNavigatorLayoutContext: Equatable {
insets.right = horizontalInsets
return insets
}
}
}
@@ -11,6 +11,10 @@ public enum RDEPUBSafeArea {
/// paddings, not approximations of the safe area itself.
public static let minimumVerticalTextMargin: CGFloat = 20
/// Compact top/bottom padding used by a landscape two-page spread. This
/// applies only beyond the actual safe area, which is always preserved.
public static let landscapeVerticalTextMargin: CGFloat = 12
public static let minimumHorizontalTextMargin: CGFloat = 16
public static func keyWindow() -> UIWindow? {
@@ -25,10 +25,22 @@ extension RDEPUBWebView {
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)
@@ -130,3 +142,110 @@ private final class RDEPUBWeakScriptMessageHandler: NSObject, WKScriptMessageHan
target?.userContentController(userContentController, didReceive: message)
}
}
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).
static let readerPageMediaLifecycleUserScript = #"""
(() => {
if (window.__rdReaderMediaLifecycleInstalled) return;
window.__rdReaderMediaLifecycleInstalled = true;
let isActive = false;
const trackedMedia = new Set();
const command = "rd-reader-page-visibility";
const mediaPrototype = window.HTMLMediaElement && window.HTMLMediaElement.prototype;
const nativePlay = mediaPrototype && mediaPrototype.play;
const register = media => {
if (media) trackedMedia.add(media);
return media;
};
const collectDocumentMedia = () => {
if (!document.querySelectorAll) return;
document.querySelectorAll("audio,video").forEach(register);
};
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(() => {});
} catch (_) {}
}
} else {
if (media.__rdReaderWasPlayed || media.autoplay || (!media.paused && !media.ended)) {
media.__rdReaderReplayWhenVisible = true;
}
if (!media.paused) {
try { media.pause(); } catch (_) {}
}
}
});
};
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;
}
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 (_) {}
}
});
document.addEventListener("DOMContentLoaded", () => setActive(isActive));
if (window.MutationObserver) {
new MutationObserver(() => setActive(isActive)).observe(document, {
childList: true,
subtree: true
});
}
})();
"""#
func setReaderPageVisible(_ isVisible: Bool) {
isReaderPageVisible = isVisible
applyReaderPageMediaVisibility()
}
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)
}
}
@@ -31,6 +31,7 @@ extension RDEPUBWebView: WKNavigationDelegate {
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
RDEPUBWebViewDebug.logNavigationEvent(debugScope, webView: webView, event: "didFinish", url: webView.url)
applyReaderPageMediaVisibility()
applyPresentation()
}
@@ -136,6 +136,8 @@ public final class RDEPUBWebView: UIView {
var isFixedLayout = false
var isReaderPageVisible = true
var pendingProgressionRequest = false
var fixedLayoutReadyWorkItem: DispatchWorkItem?
@@ -186,6 +188,7 @@ public final class RDEPUBWebView: UIView {
pendingHighlights = []
fixedSpread = nil
isFixedLayout = false
isReaderPageVisible = true
pendingProgressionRequest = false
didRenderCurrentRequest = false
teardownWebView()
@@ -55,7 +55,6 @@
<script>
(function() {
var Fit = { AUTO: 'auto', PAGE: 'page', WIDTH: 'width' };
var safeAreaInsets = { top: {{INSET_TOP}}, right: {{INSET_RIGHT}}, bottom: {{INSET_BOTTOM}}, left: {{INSET_LEFT}} };
var viewportSize = { width: {{VIEWPORT_WIDTH}}, height: {{VIEWPORT_HEIGHT}} };
var fit = '{{FIT_MODE}}';
var pendingFrames = 0;
@@ -206,10 +205,9 @@
}
if (widthFit && scaledHeight > localViewport.height) {
offsetY = safeAreaInsets.top;
offsetY = 0;
} else {
offsetY = (localViewport.height - scaledHeight) / 2;
offsetY += (safeAreaInsets.top - safeAreaInsets.bottom) / 2;
}
iframe.style.left = offsetX + 'px';