diff --git a/.github/workflows/ios-app-build.yml b/.github/workflows/ios-app-build.yml index 560b9e19..01bb973d 100644 --- a/.github/workflows/ios-app-build.yml +++ b/.github/workflows/ios-app-build.yml @@ -58,6 +58,9 @@ jobs: - name: Run shared JVM tests run: ./gradlew :shared:jvmTest --no-daemon + - name: Check video pager structure + run: bash scripts/check_video_pager_structure.sh + - name: Generate Xcode project run: xcodegen generate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7bfcd340..6b8673e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,6 +89,9 @@ jobs: - name: Run shared JVM tests run: ./gradlew :shared:jvmTest --no-daemon + - name: Check video pager structure + run: bash scripts/check_video_pager_structure.sh + - name: Generate Xcode project run: xcodegen generate diff --git a/docs/agent-logs/2026-06-02-1727-video-detail-horizontal-pager-gesture.md b/docs/agent-logs/2026-06-02-1727-video-detail-horizontal-pager-gesture.md new file mode 100644 index 00000000..6d9131e7 --- /dev/null +++ b/docs/agent-logs/2026-06-02-1727-video-detail-horizontal-pager-gesture.md @@ -0,0 +1,50 @@ +# Agent Log: Video Detail Horizontal Pager Gesture + +## User Input + +Original: + +```text +查看 han1viewer-ios这个库,并调查:为什么视频详情页的横滑手势完全失效了 +修复它:我想要的效果是,触发横滑之后完全不触发滚动,同理触发滚动后完全不触发横滑,尝试做完整的修复 +需要推送触发pr进行ci编译 +``` + +English translation: + +```text +Inspect the han1viewer-ios repository and investigate why the horizontal swipe gesture on the video detail page is completely broken. +Fix it: the desired behavior is that once horizontal paging is triggered, scrolling must not trigger at all; likewise once scrolling is triggered, horizontal paging must not trigger at all. Try to make a complete fix. +Push it to trigger PR CI compilation. +``` + +## What Changed + +- Reworked the video detail tab pager gesture arbitration in `iosApp/VideoDetailView.swift`. +- Replaced the background ancestor-level horizontal pan installer with a coordinator that installs a dedicated horizontal paging `UIPanGestureRecognizer` directly on each tab-owned `UIScrollView`. +- Made each vertical `UIScrollView.panGestureRecognizer` require the horizontal paging pan to fail before vertical scrolling can begin. +- Kept simultaneous recognition disabled, so horizontal paging and vertical scrolling cannot both win for the same touch sequence. +- Moved SwiftUI state callbacks into the `UIViewRepresentable` coordinator and weakly referenced them from the shared gesture coordinator to avoid retaining the page through closure chains. + +## Why + +The previous implementation depended on a SwiftUI background view walking up a private UIKit view hierarchy and attaching the horizontal pan to a guessed ancestor view. The tab contents are themselves `ScrollView`s, so the nested vertical scroll recognizer could win the gesture before horizontal paging ever reached `.began`. + +The new implementation puts both recognizers in the same `UIScrollView` gesture arena and establishes an explicit failure dependency: + +- Horizontal-looking gestures satisfy the custom pan's `gestureRecognizerShouldBegin`, so the scroll view pan fails and no vertical scroll happens. +- Vertical-looking gestures fail the custom pan, allowing the scroll view pan to proceed normally. + +## Mistakes Or Failed Attempts + +- The first local edit kept horizontal callbacks directly on `VideoDetailGestureCoordinator`. I changed that before committing because the coordinator is owned by SwiftUI state and those escaping closures can retain view state longer than intended. +- The first edit also left a short-drag no-page-change path that could keep a non-zero pager translation. I changed `finishHorizontalPaging` so it always resets the visual translation. + +## Verification + +- Ran `git diff --check`; it passed. +- Checked this environment for `xcodebuild` and `swiftc`; neither is available on this Linux aarch64 machine, so local Swift/iOS compilation cannot be performed here. + +## Known Limits + +- iOS build and runtime gesture verification must be done by GitHub Actions/macOS and a device or simulator test. diff --git a/docs/agent-logs/2026-06-02-1754-hide-series-video-metadata-footer.md b/docs/agent-logs/2026-06-02-1754-hide-series-video-metadata-footer.md new file mode 100644 index 00000000..bfb5787d --- /dev/null +++ b/docs/agent-logs/2026-06-02-1754-hide-series-video-metadata-footer.md @@ -0,0 +1,37 @@ +# Agent Log: Hide Series Video Metadata Footer + +## User Input + +Original: + +```text +目前版本的“系列影片”一栏和界面都有问题,标题卡片下方的作者和发布时间没法正常显示。 +那就不需要实现了,直接对这个系列影片特殊处理,不显示那一行 +``` + +English translation: + +```text +In the current version, the "Series Videos" section and UI have problems; the author and publish time under the title card cannot display normally. +Then there is no need to implement parsing; just special-case this Series Videos section and do not show that line. +``` + +## What Changed + +- Added a `showsMetadataFooter` flag to `RelatedVideoCard`. +- Passed that flag through `HorizontalVideoSection` and `RelatedVideoListView`. +- Set `showsMetadataFooter: false` for the video detail "系列影片" section only. + +## Why + +The series playlist data currently does not reliably include author and upload-time metadata, and the user requested a direct UI special case instead of parser work. Hiding the footer avoids placeholder or broken-looking metadata in both the inline horizontal section and the "更多" list. + +## Verification + +- Ran `git diff --check`; it passed. +- Checked Swift call sites for `HorizontalVideoSection`, `RelatedVideoListView`, and `RelatedVideoCard`. + +## Known Limits + +- This intentionally does not add series playlist author/upload-time parsing. +- This Linux environment cannot run Xcode/iOS compilation locally. diff --git a/docs/agent-logs/2026-06-02-1758-fix-autoplay-load-paused.md b/docs/agent-logs/2026-06-02-1758-fix-autoplay-load-paused.md new file mode 100644 index 00000000..d434b087 --- /dev/null +++ b/docs/agent-logs/2026-06-02-1758-fix-autoplay-load-paused.md @@ -0,0 +1,35 @@ +# Agent Log: Fix Autoplay Disabled Loading + +## User Input + +Original: + +```text +接下来修复“打开视频时自动播放”这个开关。预期的行为是关闭这个开关后正常加载但是加载出之后不开始播放,但是现在的行为是连加载都不开始,改了它并推送 +``` + +English translation: + +```text +Next, fix the "auto-play when opening a video" switch. The expected behavior is that after turning it off, the video still loads normally but does not start playing after it has loaded. The current behavior is that loading does not even start. Change it and push it. +``` + +## What Changed + +- Updated `KSPlayerView.makeKSOptions` so `KSOptions.isAutoPlay` stays `true`. +- Left the user preference enforcement in `onStateChanged`: once KSPlayer reaches `.bufferFinished`, the app calls `play()` or `pause()` according to `autoPlayOnEnter`. +- Updated the diagnostic mount log label from `ksAutoPlay` to `ksLoadAutoPlay` to make the distinction explicit. + +## Why + +KSPlayer uses `KSOptions.isAutoPlay` as part of whether it begins opening/buffering the URL. Binding it directly to the user preference caused the "auto-play off" state to prevent loading entirely. Keeping the KSPlayer loading switch on and pausing after the first ready state matches the intended behavior: load the video, but wait for user input before playback. + +## Verification + +- Ran `git diff --check`; it passed. +- Ran `./gradlew :shared:jvmTest`; it passed. +- PR CI will verify Swift/iOS compilation after push. + +## Known Limits + +- This Linux environment cannot run Xcode/iOS compilation locally. diff --git a/docs/agent-logs/2026-06-02-1817-hide-inline-player-speed-quality.md b/docs/agent-logs/2026-06-02-1817-hide-inline-player-speed-quality.md new file mode 100644 index 00000000..8af21b40 --- /dev/null +++ b/docs/agent-logs/2026-06-02-1817-hide-inline-player-speed-quality.md @@ -0,0 +1,38 @@ +# Agent Log: Hide Inline Player Speed And Quality Controls + +## User Input + +Original: + +```text +能不能让播放器在不全屏的时候不显示播放倍速和视频画质的按钮?全屏后再显示,防止让进度条变成短短的一条 +``` + +English translation: + +```text +Can the player hide the playback speed and video quality buttons when it is not fullscreen, and show them only after entering fullscreen, so the progress bar does not become very short? +``` + +## What Changed + +- Updated `iosApp/KSPlayerView.swift` so the playback speed menu and quality menu are only rendered when `isFullscreen` is true. +- Left the fullscreen button visible in both inline and fullscreen modes. +- Preserved the existing behavior that the quality menu only appears when there is more than one playback source. + +## Why + +The inline video player has limited horizontal space. Rendering play/pause, timestamps, the progress slider, playback speed, quality, and fullscreen controls in one row made the slider too short. Hiding speed and quality while inline keeps the main playback controls usable, while still exposing those menus in fullscreen where there is enough room. + +## Mistakes Or Failed Attempts + +- None. + +## Verification + +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. Kotlin/Native is still unsupported on this host, so the actual iOS Swift build is left to GitHub Actions. + +## Known Limits + +- This is a visibility/layout change only. The selected playback rate and source state are unchanged. diff --git a/docs/agent-logs/2026-06-02-1824-fix-autoplay-paused-state.md b/docs/agent-logs/2026-06-02-1824-fix-autoplay-paused-state.md new file mode 100644 index 00000000..5f475c9b --- /dev/null +++ b/docs/agent-logs/2026-06-02-1824-fix-autoplay-paused-state.md @@ -0,0 +1,38 @@ +# Agent Log: Fix Autoplay Disabled Paused State + +## User Input + +Original: + +```text +自动播放修复的逻辑有一点点小问题:加载好后播放器没有认为自己是暂停状态,双击之后反而是暂停,顺便还导致了加载好之后视频没有在播放但是上下滑动下方view时不会让视频自动收起 +另外,记得使用--exit-status来监控 +``` + +English translation: + +```text +There is a small issue with the autoplay fix: after loading, the player does not think it is paused. Double-tapping pauses instead, and after loading the video is not playing but scrolling the lower view does not automatically collapse the video. Also, remember to use --exit-status for monitoring. +``` + +## What Changed + +- Updated `iosApp/KSPlayerView.swift` so the first `.bufferFinished` autoplay enforcement also updates the local `isPlaying` value with the action that was actually applied. +- When `auto_play_on_enter` is disabled, the player still loads, then pauses, and now explicitly reports paused state to the parent view. + +## Why + +The previous fix paused the KSPlayer layer after buffering, but then continued to copy `state.isPlaying` from the original `.bufferFinished` callback. That old value could remain true even after `layer.pause()`, leaving SwiftUI and `VideoDetailView` believing playback was active. This broke double-tap play/pause semantics and prevented the paused-only scroll collapse behavior. + +## Mistakes Or Failed Attempts + +- The first autoplay-disabled loading fix handled the media loading behavior but missed the SwiftUI playing-state mirror used by controls and parent layout. + +## Verification + +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. Kotlin/Native remains unsupported on this host, so the iOS Swift build is verified through GitHub Actions with `--exit-status`. + +## Known Limits + +- This fixes state mirroring for the autoplay-disabled startup pause. It does not change KSPlayer's underlying state enum semantics. diff --git a/docs/agent-logs/2026-06-02-1845-review-fixes-and-comment-composer.md b/docs/agent-logs/2026-06-02-1845-review-fixes-and-comment-composer.md new file mode 100644 index 00000000..db2bb00b --- /dev/null +++ b/docs/agent-logs/2026-06-02-1845-review-fixes-and-comment-composer.md @@ -0,0 +1,44 @@ +# Agent Log: Playback Layout Review Fixes And Comment Composer + +## User Input + +Original: + +```text +把你总结的五个问题一个一个修复。顺便把评论区的发送评论按钮换成输入框放在屏幕底部,使用原生组件(iOS26时为透明玻璃,之前的系统版本兼容成普通的屏幕底部输入框 +``` + +English translation: + +```text +Fix the five issues you summarized one by one. Also replace the comment area's send-comment button with an input field at the bottom of the screen, using native components: transparent glass on iOS 26, and a normal bottom-screen input field on earlier system versions. +``` + +## What Changed + +- Updated fullscreen player controls to respect safe-area insets, so top and bottom chrome avoid notches, rounded corners, and the home indicator. +- Made the player bottom control bar responsive: time labels hide on narrow widths and the progress slider gets layout priority. +- Fixed the manually collapsed player strip layout so the lower content uses the visible 50-point strip height instead of reserving the larger follow-collapse minimum height. +- Tightened the iPad two-column threshold so the related sidebar only appears when there is enough width for both a 620-point player column and a 360-point sidebar. +- Constrained long tag chips to a single truncated line so they cannot push the detail page wider than the screen. +- Removed the main comment compose button from the comment header and added a native bottom comment composer on the comments tab. +- Used SwiftUI's iOS 26 `glassEffect` for the bottom composer chrome, with a `regularMaterial` rounded container fallback on earlier iOS versions. +- Made `CommentViewModel.postComment(text:)` report whether a send actually started, so the bottom composer only clears after local validation passes. + +## Why + +The playback page had several layout edges that were individually small but visible on real devices: fullscreen controls could sit too close to unsafe screen edges, the progress slider could still be squeezed, collapsed-player content had a bottom-height mismatch, iPad split mode activated too early, and long tags could overflow. The comment action also required a modal sheet for a short message, while the desired interaction is a persistent native composer at the bottom of the comments screen. + +## Mistakes Or Failed Attempts + +- None. + +## Verification + +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. Kotlin/Native is unsupported on this host, so iOS/Swift verification must run on GitHub Actions with Xcode 26. + +## Known Limits + +- The iOS 26 glass API is guarded with `#available(iOS 26.0, *)`; final validation depends on the CI Xcode 26 build. +- Reply composition still uses the existing sheet flow; this change only replaces the main comment composer button. diff --git a/docs/agent-logs/2026-06-02-1901-fix-player-state-and-comment-composer.md b/docs/agent-logs/2026-06-02-1901-fix-player-state-and-comment-composer.md new file mode 100644 index 00000000..58e744a4 --- /dev/null +++ b/docs/agent-logs/2026-06-02-1901-fix-player-state-and-comment-composer.md @@ -0,0 +1,43 @@ +# Agent Log: Fix Player State And Comment Composer + +## User Input + +Original: + +```text +那个输入栏改的不好。你先搜搜有没有最佳实现。另外你把播放器改坏了,现在(关闭打开视频时自动播放功能)正在播放的时候播放器不会认为正在播放,导致双击播放暂停等等功能全部失效了 +``` + +English translation: + +```text +That input bar change is not good. First search for a best-practice implementation. Also, you broke the player: now when "autoplay on opening video" is disabled, while the video is playing the player does not think it is playing, which breaks double-tap play/pause and related features. +``` + +## What Changed + +- Reworked the main comment composer so it is attached to the comments `ScrollView` with `safeAreaInset(edge: .bottom)` instead of a manual screen-covering overlay. +- Kept the composer as a native bottom bar: iOS 26 uses SwiftUI `glassEffect` on the input field; older iOS versions use a normal `.bar` bottom background with a system secondary input capsule. +- Switched the composer to `TextField(_:text:axis:)` with a 1-to-4-line limit, so short comments stay compact and longer comments can grow without opening a sheet. +- Fixed the player playing-state mirror by updating SwiftUI state immediately when user actions call `play()` or `pause()`. +- Stopped treating a non-playing `.bufferFinished` callback as authoritative after autoplay enforcement, so KSPlayer's ready/buffered state no longer overwrites manual play state. + +## Why + +Apple's SwiftUI layout APIs support inserting bottom UI with safe-area-aware modifiers instead of manually overlaying controls and guessing heights. The previous implementation manually placed the composer over the whole detail panel and padded the scroll content by an estimated height, which is fragile around keyboard, home indicator, paging, and rotation. + +The player regression came from relying on KSPlayer's state callback to mirror playing state. With autoplay disabled, the layer is intentionally paused after buffering, but later manual `play()` can leave the callback in `.bufferFinished` while playback is active. The UI state must mirror explicit user play/pause commands as well. + +## Mistakes Or Failed Attempts + +- The previous composer implementation used a manual overlay and reserved-height padding instead of safe-area insertion. +- The previous autoplay-disabled fix synchronized the initial paused state, but did not keep later manual play/pause transitions authoritative. + +## Verification + +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. Kotlin/Native is unsupported on this host, so the iOS Swift build is verified through GitHub Actions. + +## Known Limits + +- The iOS 26 glass appearance is compile-guarded with `#available(iOS 26.0, *)`; final Swift API verification is done by CI on Xcode 26. diff --git a/docs/agent-logs/2026-06-02-1922-float-comment-composer.md b/docs/agent-logs/2026-06-02-1922-float-comment-composer.md new file mode 100644 index 00000000..2621b8df --- /dev/null +++ b/docs/agent-logs/2026-06-02-1922-float-comment-composer.md @@ -0,0 +1,36 @@ +# Agent Log: Float Comment Composer + +## User Input + +Original: + +```text +评论按钮实现依然不对:我希望它悬浮在简介/评论 view上面,检测到在评论view里面就自动出现,如果不在就自动消失,而不是把它整个放进评论区的scroll view里面。然后这个输入框我希望它能跟随输入法上边缘浮起,而不是被输入法挡住。先搜索再做 +``` + +English translation: + +```text +The comment button implementation is still wrong: I want it to float over the introduction/comments views, automatically appear when the comments view is active, and disappear when it is not. It should not be placed inside the comments scroll view. The input field should also float with the keyboard's top edge instead of being covered by the keyboard. Search first, then implement. +``` + +## What Changed + +- Moved the main comment composer out of the comments `ScrollView`. +- Mounted the composer as a floating overlay on the introduction/comments pager container. +- The composer now appears only when the active tab is `.comments`, and disappears on `.introduction`. +- Marked the floating composer as a horizontal-pager exclusion area so input/button touches do not trigger page swipes. +- Stopped ignoring keyboard safe-area regions in inline video detail mode by switching to container-only safe-area ignoring, and only in fullscreen. +- Added interactive keyboard dismissal to the tab scroll views. +- Added a little extra bottom padding to the comments content so the floating composer does not cover the last visible comments. + +## Why + +Apple's SwiftUI safe-area model treats keyboard avoidance as a safe-area adjustment. The previous root-level safe-area ignore used the default safe-area regions, which can include the keyboard region. Keeping inline mode inside the keyboard-safe layout lets the floating composer move with the keyboard without manual keyboard-height observers. + +The previous composer was attached to the comments `ScrollView` as a bottom inset. That made it part of the scroll view's layout instead of a layer controlled by the active pager tab. + +## Verification + +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. Kotlin/Native remains unsupported on this host, so the iOS Swift build is verified through GitHub Actions. diff --git a/docs/agent-logs/2026-06-02-1937-anchor-comment-composer-safe-area.md b/docs/agent-logs/2026-06-02-1937-anchor-comment-composer-safe-area.md new file mode 100644 index 00000000..621161fa --- /dev/null +++ b/docs/agent-logs/2026-06-02-1937-anchor-comment-composer-safe-area.md @@ -0,0 +1,31 @@ +# Agent Log: Anchor Comment Composer Safe Area + +## User Input + +Original: + +```text +能不能把评论框铆定在屏幕内部?现在还是会出屏幕外。另外现在安全区设计又出问题了,scrollview无论何时都与屏幕最下面有一层gap +``` + +English translation: + +```text +Can you anchor the comment box inside the screen? It still goes outside the screen now. Also the safe-area design is broken again: the scroll view always has a gap above the bottom of the screen. +``` + +## What Changed + +- Restored inline video-detail content to ignore only the container bottom safe area, not the keyboard safe area. +- Passed the outer geometry bottom safe-area inset into the floating comment composer. +- Added the bottom safe-area inset inside the composer chrome so the input field stays above the home indicator. +- Increased comments tab bottom content clearance by the same inset so the floating composer does not cover the last comments. + +## Why + +The previous change stopped ignoring the container bottom safe area in inline mode. That fixed keyboard avoidance but reintroduced the visible bottom gap under the pager/scroll area. The correct split is to ignore only `.container` bottom for the page, while keeping keyboard safe-area avoidance active. The floating composer then needs its own bottom inset so it remains inside the screen. + +## Verification + +- Superseded by the root-level composer change logged in `2026-06-02-1942-root-comment-composer.md`. +- Local checks and GitHub Actions CI are run after both changes together. diff --git a/docs/agent-logs/2026-06-02-1942-root-comment-composer.md b/docs/agent-logs/2026-06-02-1942-root-comment-composer.md new file mode 100644 index 00000000..ba2ce7dd --- /dev/null +++ b/docs/agent-logs/2026-06-02-1942-root-comment-composer.md @@ -0,0 +1,41 @@ +# Agent Log: Root Comment Composer + +## User Input + +Original: + +```text +评论框为什么不能在整个view的最上层呢,就像首页的tab栏一样 +还有点击查看x条回复的时候,回复别人的时候,这个输入框都要隐藏 +``` + +English translation: + +```text +Why can't the comment box be at the top layer of the whole view, like the home tab bar? +Also, when tapping to view x replies or replying to someone, this input box should be hidden. +``` + +## What Changed + +- Promoted the main comment composer to a root-level `VideoDetailView` overlay, above the player/detail/pager content stack. +- Scoped safe-area ignoring to the content layer, so the detail scroll area can still reach the screen bottom while the composer remains anchored as page chrome. +- Moved horizontal pager exclusion-frame collection to the root layer so touches on the composer do not start horizontal paging. +- Added a `CommentView` callback for internal comment overlays. +- Hid the root composer while reply sheets or reply-thread sheets are active. + +## Why + +The composer is page chrome, not comments-list content. Keeping it at the root layer avoids clipping and frame constraints from the pager and scroll views, matching the way a tab bar floats above page content. + +Reply and reply-thread flows have their own input context. Showing the root-level main comment composer under those flows is visually confusing and can compete with the sheet's focused input. + +## Verification + +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. Kotlin/Native remains unsupported on this host, so the iOS Swift build is verified through GitHub Actions. + +## Follow-Up + +- First GitHub Actions Swift build failed because `belowPlayerScroll -> some View` gained a local `let` before the view expression and therefore needed an explicit `return`. +- Added the explicit `return` before re-running checks. diff --git a/docs/agent-logs/2026-06-02-2014-comment-composer-keyboard-glass.md b/docs/agent-logs/2026-06-02-2014-comment-composer-keyboard-glass.md new file mode 100644 index 00000000..85e339b8 --- /dev/null +++ b/docs/agent-logs/2026-06-02-2014-comment-composer-keyboard-glass.md @@ -0,0 +1,43 @@ +# Agent Log: Comment Composer Keyboard and Glass + +## User Input + +Original: + +```text +输入框的位置不对,首先就是在没有激活输入的时候位置太高,离屏幕底部太远,我希望他和tabbar的高度是一样的。然后就是激活输入后,输入框的位置达到了两倍的输入法高度以上,你搜搜应该怎么正确处理 +还有,当前输入框的处理对于iPad来说是不是有问题?检查一下(我自己还没测试过,请你看看代码) +另外,当前输入框虽然有玻璃效果,但是没有弹性效果,旁边的发送按钮页也没有玻璃效果和弹性效果,请你针对iOS26继续看文档代码 +``` + +English translation: + +```text +The input field position is wrong. First, when input is inactive it is too high and too far from the bottom; I want it to match the tab bar height. Then when input is active, it rises to more than twice the keyboard height. Search how this should be handled correctly. +Also, is the current input handling a problem for iPad? Check the code, I haven't tested it myself yet. +Also, the current input field has a glass effect but no elastic effect, and the send button has neither glass nor elastic effect. Continue checking the iOS 26 docs/code. +``` + +## What Changed + +- Removed manual bottom safe-area padding from the root comment composer so SwiftUI keyboard avoidance is not double-counted. +- Made the compact composer chrome use a 49 pt minimum height, matching the tab-bar content height expectation when the keyboard is inactive. +- Kept comment-list bottom clearance separate from the composer's actual position. +- Detected keyboard safe-area activation by comparing SwiftUI geometry safe-area bottom against the key window's container bottom inset, rather than using input focus. +- Wrapped the iOS 26 comment controls in `GlassEffectContainer`. +- Changed the input capsule to `.glassEffect(.regular.interactive(), in: Capsule())`. +- Added an iOS 26 circular interactive glass effect to the send button while preserving the fixed 42 pt hit target. + +## Why + +SwiftUI already treats the software keyboard as a safe-area adjustment when the view does not ignore the keyboard safe area. Manually adding the geometry bottom inset to the composer causes the focused bar to move by the keyboard height and then add the same inset again. + +Focus is not a reliable keyboard-height signal on iPad because hardware keyboards and floating/split software keyboards can focus a text field without producing a full bottom keyboard safe area. The composer should stay in normal root layout and let the system keyboard safe area move it only when the system actually exposes that inset. + +Apple's Liquid Glass documentation says custom components need `interactive(_:)` to get the same responsive touch and pointer reaction that standard glass buttons provide, and recommends `GlassEffectContainer` when multiple glass shapes are used together. + +## Verification + +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. +- GitHub Actions `iOS App Build` passed on run `26819040731` with Xcode 26.2, including unsigned device app build, IPA packaging, and upload. diff --git a/docs/agent-logs/2026-06-02-2034-ipad-comment-composer-width.md b/docs/agent-logs/2026-06-02-2034-ipad-comment-composer-width.md new file mode 100644 index 00000000..0564bf51 --- /dev/null +++ b/docs/agent-logs/2026-06-02-2034-ipad-comment-composer-width.md @@ -0,0 +1,31 @@ +# Agent Log: iPad Comment Composer Width + +## User Input + +Original: + +```text +现在布局问题不大了,但是iPad上还有点小问题,就是iPad上的评论输入框太宽了,挡住了旁边的相关影片列表一小部分,能做特殊处理吗,先告诉我能还是不能 +好的,开始做吧 +``` + +English translation: + +```text +The layout is mostly fine now, but there is still a small issue on iPad: the comment input is too wide and covers part of the related videos list. Can this be specially handled? First tell me whether it can be done. +Okay, start doing it. +``` + +## What Changed + +- Reused the iPad two-column layout calculation for both the left video/detail panel and the root comment composer. +- Constrained the root comment composer to the left panel width only when the iPad related-videos sidebar is active. +- Kept phone, iPad portrait, narrow iPad split-view, and fullscreen behavior on the existing full-width path. + +## Why + +The root comment composer is page chrome, but in iPad landscape two-column mode the page chrome should belong to the left video/detail panel. Letting it span the full root view makes it overlap the right related-videos sidebar. + +## Verification + +- Pending local diff check and GitHub Actions CI after push. diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift new file mode 100644 index 00000000..3fe46f59 --- /dev/null +++ b/iosApp/CommentListTableView.swift @@ -0,0 +1,535 @@ +import SwiftUI +import UIKit + +struct CommentListTableModel { + let state: CommentViewModel.State + let sortMode: CommentViewModel.SortMode + let runningActionIDs: Set + let comments: [CommentRow] + let onChangeSortMode: (CommentViewModel.SortMode) -> Void + let onRefresh: () -> Void + let onRetry: () -> Void + let onReply: (CommentRow) -> Void + let onShowReplies: (CommentRow) -> Void + let onLike: (CommentRow) -> Void + let onDislike: (CommentRow) -> Void + let onReport: (CommentRow) -> Void +} + +final class CommentListTableController: NSObject, UITableViewDataSource, UITableViewDelegate { + private enum StateSignature: Equatable { + case idle + case loading + case loaded + case failed(String) + + init(_ state: CommentViewModel.State) { + switch state { + case .idle: + self = .idle + case .loading: + self = .loading + case .loaded: + self = .loaded + case .failed(let message): + self = .failed(message) + } + } + } + + private struct ModelSignature: Equatable { + let state: StateSignature + let sortMode: CommentViewModel.SortMode + let comments: [CommentSignature] + + init(_ model: CommentListTableModel) { + state = StateSignature(model.state) + sortMode = model.sortMode + comments = model.comments.map(CommentSignature.init) + } + } + + private struct CommentSignature: Equatable { + let id: String + let username: String + let date: String + let content: String + let hasMoreReplies: Bool + let replyCount: Int? + + init(_ comment: CommentRow) { + id = comment.id + username = comment.username + date = comment.date + content = comment.content + hasMoreReplies = comment.hasMoreReplies + replyCount = comment.replyCount + } + } + + private struct CommentActionSignature: Equatable { + let id: String + let thumbUp: Int? + let likeCommentStatus: Bool + let unlikeCommentStatus: Bool + + init(_ comment: CommentRow) { + id = comment.id + thumbUp = comment.thumbUp + likeCommentStatus = comment.likeCommentStatus + unlikeCommentStatus = comment.unlikeCommentStatus + } + } + + private enum Row { + case controls + case loading + case failed(String) + case empty + case comment(CommentRow) + case footer + } + + private(set) weak var tableView: UITableView? + private var rows: [Row] = [] + private var sortMode: CommentViewModel.SortMode = .mostLikes + private var comments: [CommentRow] = [] + private var runningActionIDs: Set = [] + private var onChangeSortMode: (CommentViewModel.SortMode) -> Void = { _ in } + private var onRefresh: () -> Void = {} + private var onRetry: () -> Void = {} + private var onReply: (CommentRow) -> Void = { _ in } + private var onShowReplies: (CommentRow) -> Void = { _ in } + private var onLike: (CommentRow) -> Void = { _ in } + private var onDislike: (CommentRow) -> Void = { _ in } + private var onReport: (CommentRow) -> Void = { _ in } + private var modelSignature: ModelSignature? + private var commentActionSignatures: [CommentActionSignature] = [] + private var previousRunningActionIDs: Set = [] + private var hasRenderedRows = false + private var contentBottomPadding: CGFloat = 0 + weak var scrollDelegate: UIScrollViewDelegate? + + func attach(_ tableView: UITableView) { + self.tableView = tableView + configure(tableView) + tableView.dataSource = self + tableView.delegate = self + } + + func makeTableView() -> UITableView { + let tableView = UITableView(frame: .zero, style: .plain) + attach(tableView) + return tableView + } + + func update(_ model: CommentListTableModel) { + let nextSignature = ModelSignature(model) + let shouldReload = modelSignature != nextSignature + let nextCommentActionSignatures = model.comments.map(CommentActionSignature.init) + let didChangeCommentActions = commentActionSignatures != nextCommentActionSignatures + let didChangeRunningActions = previousRunningActionIDs != model.runningActionIDs + modelSignature = nextSignature + commentActionSignatures = nextCommentActionSignatures + previousRunningActionIDs = model.runningActionIDs + sortMode = model.sortMode + comments = model.comments + runningActionIDs = model.runningActionIDs + onChangeSortMode = model.onChangeSortMode + onRefresh = model.onRefresh + onRetry = model.onRetry + onReply = model.onReply + onShowReplies = model.onShowReplies + onLike = model.onLike + onDislike = model.onDislike + onReport = model.onReport + let shouldReloadForActionChange = didChangeCommentActions + && (model.sortMode == .mostLikes || model.sortMode == .mostDislikes) + guard shouldReload else { + if shouldReloadForActionChange { + rows = rows(for: model) + reloadTablePreservingOffset() + } else if didChangeRunningActions || didChangeCommentActions { + rows = rows(for: model) + updateVisibleCommentRows() + } + return + } + rows = rows(for: model) + reloadTablePreservingOffset() + } + + func updateContentBottomPadding(_ bottomPadding: CGFloat) { + let nextPadding = max(bottomPadding, 0) + guard let tableView else { + contentBottomPadding = nextPadding + return + } + guard abs(contentBottomPadding - nextPadding) > 0.5 + || abs(tableView.contentInset.bottom - nextPadding) > 0.5 + || abs(tableView.verticalScrollIndicatorInsets.bottom - nextPadding) > 0.5 else { + return + } + applyContentBottomPadding(nextPadding, in: tableView) + } + + private func applyContentBottomPadding(_ nextPadding: CGFloat, in tableView: UITableView) { + contentBottomPadding = nextPadding + let bottomInset = max(nextPadding, 0) + if abs(tableView.contentInset.bottom - bottomInset) > 0.5 { + tableView.contentInset.bottom = bottomInset + } + if abs(tableView.verticalScrollIndicatorInsets.bottom - bottomInset) > 0.5 { + tableView.verticalScrollIndicatorInsets.bottom = bottomInset + } + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + rows.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch rows[indexPath.row] { + case .controls: + let cell = tableView.dequeueReusableCell( + withIdentifier: HostingCommentTableViewCell.reuseIdentifier, + for: indexPath + ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + cell.configure { + CommentListControlsRowView( + sortMode: sortMode, + onChangeSortMode: { [weak self] mode in self?.onChangeSortMode(mode) }, + onRefresh: { [weak self] in self?.onRefresh() } + ) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 6) + } + return cell + case .loading: + let cell = tableView.dequeueReusableCell( + withIdentifier: HostingCommentTableViewCell.reuseIdentifier, + for: indexPath + ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + cell.configure { + CommentListLoadingRowView() + } + return cell + case .failed(let message): + let cell = tableView.dequeueReusableCell( + withIdentifier: HostingCommentTableViewCell.reuseIdentifier, + for: indexPath + ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + cell.configure { + CommentListFailedRowView( + message: message, + onRetry: { [weak self] in self?.onRetry() } + ) + } + return cell + case .empty: + let cell = tableView.dequeueReusableCell( + withIdentifier: HostingCommentTableViewCell.reuseIdentifier, + for: indexPath + ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + cell.configure { + CommentListEmptyRowView() + } + return cell + case .footer: + let cell = tableView.dequeueReusableCell( + withIdentifier: CommentListFooterCell.reuseIdentifier, + for: indexPath + ) as? CommentListFooterCell ?? CommentListFooterCell(style: .default, reuseIdentifier: CommentListFooterCell.reuseIdentifier) + cell.configure() + return cell + case .comment(let comment): + let cell = tableView.dequeueReusableCell( + withIdentifier: HostingCommentTableViewCell.reuseIdentifier, + for: indexPath + ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + cell.configure( + comment: comment, + isRunningLike: runningActionIDs.contains("like-\(comment.id)"), + onReply: { [weak self] in self?.onReply(comment) }, + onShowReplies: { [weak self] in self?.onShowReplies(comment) }, + onLike: { [weak self] in self?.onLike(comment) }, + onDislike: { [weak self] in self?.onDislike(comment) }, + onReport: { [weak self] in self?.onReport(comment) } + ) + return cell + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + scrollDelegate?.scrollViewDidScroll?(scrollView) + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollDelegate?.scrollViewWillBeginDragging?(scrollView) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + scrollDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + scrollDelegate?.scrollViewDidEndDecelerating?(scrollView) + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + scrollDelegate?.scrollViewDidEndScrollingAnimation?(scrollView) + } + + private func configure(_ tableView: UITableView) { + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.showsVerticalScrollIndicator = false + tableView.estimatedRowHeight = 118 + tableView.estimatedSectionHeaderHeight = 0 + tableView.estimatedSectionFooterHeight = 0 + tableView.rowHeight = UITableView.automaticDimension + tableView.sectionHeaderHeight = 0 + tableView.sectionFooterHeight = 0 + tableView.contentInsetAdjustmentBehavior = .never + tableView.register(HostingCommentTableViewCell.self, forCellReuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + tableView.register(CommentListFooterCell.self, forCellReuseIdentifier: CommentListFooterCell.reuseIdentifier) + } + + private func rows(for model: CommentListTableModel) -> [Row] { + switch model.state { + case .idle, .loading: + return [.controls, .loading] + case .failed(let message): + return [.controls, .failed(message)] + case .loaded: + guard !model.comments.isEmpty else { + return [.controls, .empty] + } + return [.controls] + model.comments.map { .comment($0) } + [.footer] + } + } + + private func updateVisibleCommentRows() { + guard let tableView else { return } + for indexPath in tableView.indexPathsForVisibleRows ?? [] { + guard indexPath.row < rows.count, + case .comment(let comment) = rows[indexPath.row], + let cell = tableView.cellForRow(at: indexPath) as? HostingCommentTableViewCell else { + continue + } + cell.configure( + comment: comment, + isRunningLike: runningActionIDs.contains("like-\(comment.id)"), + onReply: { [weak self] in self?.onReply(comment) }, + onShowReplies: { [weak self] in self?.onShowReplies(comment) }, + onLike: { [weak self] in self?.onLike(comment) }, + onDislike: { [weak self] in self?.onDislike(comment) }, + onReport: { [weak self] in self?.onReport(comment) } + ) + } + } + + private func reloadTablePreservingOffset() { + guard let tableView else { return } + guard hasRenderedRows else { + tableView.reloadData() + tableView.layoutIfNeeded() + hasRenderedRows = true + return + } + UIView.performWithoutAnimation { + tableView.reloadData() + if !tableView.isTracking, !tableView.isDragging, !tableView.isDecelerating { + tableView.layoutIfNeeded() + } + } + } + +} + +private final class HostingCommentTableViewCell: UITableViewCell { + static let reuseIdentifier = "HostingCommentTableViewCell" + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + contentView.backgroundColor = .clear + selectionStyle = .none + preservesSuperviewLayoutMargins = false + contentView.preservesSuperviewLayoutMargins = false + layoutMargins = .zero + contentView.layoutMargins = .zero + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + contentConfiguration = nil + } + + func configure(@ViewBuilder content: () -> Content) { + contentConfiguration = UIHostingConfiguration { + content() + } + .margins(.all, 0) + } + + func configure( + comment: CommentRow, + isRunningLike: Bool, + onReply: @escaping () -> Void, + onShowReplies: @escaping () -> Void, + onLike: @escaping () -> Void, + onDislike: @escaping () -> Void, + onReport: @escaping () -> Void + ) { + let row = CommentRowView( + comment: comment, + isRunningLike: isRunningLike, + onReply: onReply, + onShowReplies: onShowReplies, + onLike: onLike, + onDislike: onDislike, + onReport: onReport + ) + .padding(.vertical, 6) + .padding(.horizontal, 16) + + contentConfiguration = UIHostingConfiguration { + row + } + .margins(.all, 0) + } +} + +private struct CommentListControlsRowView: View { + let sortMode: CommentViewModel.SortMode + let onChangeSortMode: (CommentViewModel.SortMode) -> Void + let onRefresh: () -> Void + + var body: some View { + HStack(spacing: 12) { + Menu { + ForEach(CommentViewModel.SortMode.allCases) { mode in + Button { + onChangeSortMode(mode) + } label: { + if mode == sortMode { + Label(mode.title, systemImage: "checkmark") + } else { + Text(mode.title) + } + } + } + } label: { + Label(sortMode.title, systemImage: "arrow.up.arrow.down") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + Spacer() + + TapOnlyControl(action: onRefresh) { + Image(systemName: "arrow.clockwise") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } +} + +private struct CommentListLoadingRowView: View { + var body: some View { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 60) + } +} + +private struct CommentListFailedRowView: View { + let message: String + let onRetry: () -> Void + + var body: some View { + VStack(spacing: 12) { + Image(systemName: "text.bubble") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("评论加载失败") + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + TapOnlyControl(action: onRetry) { + Text("重试") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + CloudflareVerifyButton(errorMessage: message) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.vertical, 60) + } +} + +private struct CommentListEmptyRowView: View { + var body: some View { + VStack(spacing: 12) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("暂无评论") + .font(.headline) + Text("成为第一个评论的人。") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + } +} + +private final class CommentListFooterCell: UITableViewCell { + static let reuseIdentifier = "CommentListFooterCell" + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + contentView.backgroundColor = .clear + selectionStyle = .none + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure() { + contentConfiguration = UIHostingConfiguration { + Text("comment.no_more") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .margins(.all, 0) + } +} diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 858d5727..5d26f9e9 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -1,196 +1,118 @@ import SwiftUI import Han1meShared -struct CommentView: View { - @StateObject private var viewModel: CommentViewModel - @State private var composeText = "" +struct CommentOverlayHost: View { + @ObservedObject private var viewModel: CommentViewModel + private let onOverlayActivityChanged: (Bool) -> Void + private let content: (CommentListTableModel) -> Content @State private var replyTarget: CommentRow? @State private var replyText = "" @State private var reportTarget: CommentRow? @State private var repliesTarget: CommentRow? - @State private var isShowingComposer = false - init(videoCode: String, commentFeature: CommentFeature) { - _viewModel = StateObject( - wrappedValue: CommentViewModel(feature: commentFeature, videoCode: videoCode) - ) + init( + viewModel: CommentViewModel, + onOverlayActivityChanged: @escaping (Bool) -> Void = { _ in }, + @ViewBuilder content: @escaping (CommentListTableModel) -> Content + ) { + _viewModel = ObservedObject(wrappedValue: viewModel) + self.onOverlayActivityChanged = onOverlayActivityChanged + self.content = content } var body: some View { - VStack(alignment: .leading, spacing: 12) { - header - content - } - .padding(.horizontal, 16) - .refreshable { - await viewModel.refresh() - } - .task { - viewModel.loadIfNeeded() - } - .alert("提示", isPresented: actionMessageBinding) { - Button("好", role: .cancel) { - viewModel.actionMessage = nil + content(tableModel) + .task { + viewModel.loadIfNeeded() } - } message: { - Text(viewModel.actionMessage ?? "") - } - .sheet(isPresented: $isShowingComposer) { - CommentTextSheet( - title: "发表评论", - text: $composeText, - placeholder: "输入评论", - submitTitle: "发送", - onCancel: { - isShowingComposer = false - composeText = "" - }, - onSubmit: { - viewModel.postComment(text: composeText) - isShowingComposer = false - composeText = "" - } - ) - } - .sheet(item: $replyTarget) { comment in - CommentTextSheet( - title: "回复 \(comment.username)", - text: $replyText, - placeholder: "输入回复", - submitTitle: "回复", - onCancel: { - replyTarget = nil - replyText = "" - }, - onSubmit: { - viewModel.postReply(to: comment, text: replyText) - replyTarget = nil - replyText = "" - } - ) - } - .sheet(item: $repliesTarget) { comment in - CommentRepliesSheet( - comment: comment, - viewModel: viewModel - ) { reply in - replyText = "@\(reply.username) " - replyTarget = reply + .onValueChange(of: replyTarget?.id) { _ in + notifyOverlayActivityChanged() } - } - .confirmationDialog("举报原因", isPresented: reportDialogBinding, titleVisibility: .visible) { - ForEach(viewModel.reportReasons) { reason in - Button(reason.title) { - if let reportTarget { - viewModel.report(comment: reportTarget, reason: reason) - } - reportTarget = nil - } + .onValueChange(of: repliesTarget?.id) { _ in + notifyOverlayActivityChanged() } - Button("取消", role: .cancel) { - reportTarget = nil + .onDisappear { + onOverlayActivityChanged(false) } - } - } - - private var header: some View { - HStack(spacing: 12) { - Picker("排序", selection: $viewModel.sortMode) { - ForEach(CommentViewModel.SortMode.allCases) { mode in - Text(mode.title).tag(mode) + .alert("提示", isPresented: actionMessageBinding) { + Button("好", role: .cancel) { + viewModel.actionMessage = nil } + } message: { + Text(viewModel.actionMessage ?? "") } - .pickerStyle(.menu) - - Spacer() - - Button { - isShowingComposer = true - } label: { - Label("评论", systemImage: "square.and.pencil") - } - .buttonStyle(.borderedProminent) - - Button { - viewModel.load() - } label: { - Image(systemName: "arrow.clockwise") - } - .buttonStyle(.bordered) - } - } - - @ViewBuilder - private var content: some View { - switch viewModel.state { - case .idle, .loading: - HStack { - Spacer() - ProgressView() - Spacer() + .sheet(item: $replyTarget) { comment in + CommentTextSheet( + title: "回复 \(comment.username)", + text: $replyText, + placeholder: "输入回复", + submitTitle: "回复", + onCancel: { + replyTarget = nil + replyText = "" + }, + onSubmit: { + viewModel.postReply(to: comment, text: replyText) + replyTarget = nil + replyText = "" + } + ) } - .padding(.vertical, 60) - case .failed(let message): - VStack(spacing: 12) { - Image(systemName: "text.bubble") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text("评论加载失败") - .font(.headline) - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - Button("重试") { - viewModel.load() + .sheet(item: $repliesTarget) { comment in + CommentRepliesSheet( + comment: comment, + viewModel: viewModel + ) { reply in + beginReplyFromRepliesSheet(reply) } - .buttonStyle(.borderedProminent) - CloudflareVerifyButton(errorMessage: message) } - .frame(maxWidth: .infinity) - .padding(.vertical, 60) - case .loaded: - let comments = viewModel.sortedComments - if comments.isEmpty { - VStack(spacing: 12) { - Image(systemName: "bubble.left.and.bubble.right") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text("暂无评论") - .font(.headline) - Text("成为第一个评论的人。") - .font(.subheadline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 60) - } else { - LazyVStack(alignment: .leading, spacing: 12) { - ForEach(comments) { comment in - CommentRowView( - comment: comment, - isRunningLike: viewModel.runningActionIDs.contains("like-\(comment.id)"), - onReply: { - replyText = "@\(comment.username) " - replyTarget = comment - }, - onShowReplies: { - repliesTarget = comment - }, - onLike: { - viewModel.like(comment: comment, isPositive: true) - }, - onDislike: { - viewModel.like(comment: comment, isPositive: false) - }, - onReport: { - reportTarget = comment - } - ) + .confirmationDialog("举报原因", isPresented: reportDialogBinding, titleVisibility: .visible) { + ForEach(viewModel.reportReasons) { reason in + Button(reason.title) { + if let reportTarget { + viewModel.report(comment: reportTarget, reason: reason) + } + reportTarget = nil } } + Button("取消", role: .cancel) { + reportTarget = nil + } } - } + } + + var tableModel: CommentListTableModel { + CommentListTableModel( + state: viewModel.state, + sortMode: viewModel.sortMode, + runningActionIDs: viewModel.runningActionIDs, + comments: viewModel.sortedComments, + onChangeSortMode: { mode in + viewModel.changeSortMode(mode) + }, + onRefresh: { + viewModel.load() + }, + onRetry: { + viewModel.load() + }, + onReply: { comment in + replyText = "@\(comment.username) " + replyTarget = comment + }, + onShowReplies: { comment in + repliesTarget = comment + }, + onLike: { comment in + viewModel.like(comment: comment, isPositive: true) + }, + onDislike: { comment in + viewModel.like(comment: comment, isPositive: false) + }, + onReport: { comment in + reportTarget = comment + } + ) } private var actionMessageBinding: Binding { @@ -206,9 +128,23 @@ struct CommentView: View { set: { if !$0 { reportTarget = nil } } ) } + + private func notifyOverlayActivityChanged() { + onOverlayActivityChanged(replyTarget != nil || repliesTarget != nil) + } + + private func beginReplyFromRepliesSheet(_ reply: CommentRow) { + replyText = "@\(reply.username) " + repliesTarget = nil + Task { @MainActor in + await Task.yield() + replyTarget = reply + } + } + } -private struct CommentRowView: View { +struct CommentRowView: View { let comment: CommentRow let isRunningLike: Bool let onReply: () -> Void @@ -231,9 +167,7 @@ private struct CommentRowView: View { .font(.caption) .foregroundStyle(.secondary) Spacer() - Menu { - Button("举报", role: .destructive, action: onReport) - } label: { + TapOnlyControl(action: onReport) { Image(systemName: "ellipsis") .foregroundStyle(.secondary) } @@ -241,27 +175,32 @@ private struct CommentRowView: View { Text(comment.content) .font(.body) - .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) HStack(spacing: 14) { - Button(action: onLike) { + TapOnlyControl(isDisabled: isRunningLike) { + onLike() + } label: { Label("\(comment.thumbUp ?? 0)", systemImage: comment.likeCommentStatus ? "hand.thumbsup.fill" : "hand.thumbsup") } - .disabled(isRunningLike) - Button(action: onDislike) { + TapOnlyControl(isDisabled: isRunningLike) { + onDislike() + } label: { Image(systemName: comment.unlikeCommentStatus ? "hand.thumbsdown.fill" : "hand.thumbsdown") } - .disabled(isRunningLike) - Button("回复", action: onReply) + TapOnlyControl(action: onReply) { + Text("回复") + } if comment.hasMoreReplies { - Button("查看 \(comment.replyCount ?? 0) 条回复", action: onShowReplies) + TapOnlyControl(action: onShowReplies) { + Text("查看 \(comment.replyCount ?? 0) 条回复") + } } } .font(.caption) - .buttonStyle(.plain) .foregroundStyle(.secondary) } } diff --git a/iosApp/CommentViewModel.swift b/iosApp/CommentViewModel.swift index 142b757b..7922c794 100644 --- a/iosApp/CommentViewModel.swift +++ b/iosApp/CommentViewModel.swift @@ -36,11 +36,7 @@ final class CommentViewModel: ObservableObject { } @Published private(set) var state: State = .idle - @Published var sortMode: SortMode = .latest { - didSet { - updateSortedComments() - } - } + @Published private(set) var sortMode: SortMode = .mostLikes @Published var actionMessage: String? @Published private(set) var runningActionIDs: Set = [] @Published private(set) var sortedComments: [CommentRow] = [] @@ -83,15 +79,22 @@ final class CommentViewModel: ObservableObject { await loadComments(generation: generation) } - func postComment(text: String) { + func changeSortMode(_ mode: SortMode) { + guard sortMode != mode else { return } + sortMode = mode + updateSortedComments() + } + + @discardableResult + func postComment(text: String) -> Bool { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.count >= 2 else { actionMessage = String(localized: "评论太短") - return + return false } guard let snapshot = currentSnapshot, let userId = snapshot.currentUserId else { actionMessage = String(localized: "请先登录") - return + return false } runAction(id: "post-comment") { @@ -104,6 +107,7 @@ final class CommentViewModel: ObservableObject { self.actionMessage = String(localized: "评论已发送") self.load() } + return true } func postReply(to comment: CommentRow, text: String) { @@ -204,9 +208,9 @@ final class CommentViewModel: ObservableObject { let comments = snapshot.comments switch sortMode { case .latest: - return comments - case .earliest: return Array(comments.reversed()) + case .earliest: + return comments case .mostReplies: return comments.sorted { ($0.replyCount ?? 0) > ($1.replyCount ?? 0) } case .mostLikes: diff --git a/iosApp/KSPlayerView.swift b/iosApp/KSPlayerView.swift index 965452f4..cf2aefd2 100644 --- a/iosApp/KSPlayerView.swift +++ b/iosApp/KSPlayerView.swift @@ -16,19 +16,17 @@ import AVFoundation /// 隔离**,会让外层整个 app 进入 dark mode。`KSVideoPlayerViewBuilder` 是 internal enum /// 也用不了。 /// -/// 通过 `@Binding isFullscreen` / `@Binding isCollapsed` 让外部容器(VideoDetailView) -/// 控制 player 形态。**关键**:始终在 SwiftUI view tree 同一位置,仅靠外层 frame 切换大小, +/// 通过 `@Binding isFullscreen` 让外部容器(VideoDetailView)控制 player 形态。 +/// **关键**:始终在 SwiftUI view tree 同一位置,仅靠外层 frame 切换大小, /// view identity 不变 → KSPlayerLayer 复用 → 视频不重新加载,进度不丢失。 @MainActor struct KSPlayerView: View { let snapshot: VideoDetailScreenSnapshot @Binding var isFullscreen: Bool - @Binding var isCollapsed: Bool let onProgress: (TimeInterval) -> Void let onPlaybackEnded: () -> Void /// Optional: invoked whenever the player's playing/paused state flips. - /// Used by the parent to decide whether to allow scroll-driven shrink - /// of the player area (only paused state shrinks). + /// Used by the parent for layout policy that depends on active playback. let onPlayingChanged: (Bool) -> Void /// Optional: invoked whenever the controls overlay shows / hides. Lets /// the parent slide the navigation bar in / out together with the @@ -39,17 +37,7 @@ struct KSPlayerView: View { /// removed (parent hides the navigation bar entirely), so this is the /// player's only way back. let onBack: () -> Void - /// True when the parent has shrunk the player below its 16:9 size via - /// the follow-finger collapse (paused + scrolled). When this is true, - /// a single tap on the video does NOT toggle the controls overlay — - /// it instead asks the parent to expand the player back to 16:9. This - /// is the user-requested workaround for the visible "video + controls - /// pulse" that occurred when the controls overlay materialised on - /// top of a shrunken player. - let isShrunken: Bool - /// Tap handler invoked when the user taps a shrunken player; parent is - /// expected to expand the player back to its full size. - let onRequestExpand: () -> Void + let playRequestToken: Int /// Optional: invoked the first time the underlying media reports a /// non-zero natural size. Lets the parent decide whether the video is /// landscape or portrait so it can pick the right fullscreen @@ -83,6 +71,8 @@ struct KSPlayerView: View { /// Whether `onNaturalSize` has been fired already. We only want to /// notify the parent once — the natural size won't change mid-playback. @State private var naturalSizeReported = false + @State private var lastProgressReportSeconds: TimeInterval? + @State private var lastProgressReportAt = Date.distantPast /// User-picked playback source (quality). Nil = use the snapshot's /// default-marked source via primarySource(). Wired up from the /// quality menu in bottomBar; switching value re-evaluates `body` and @@ -113,6 +103,7 @@ struct KSPlayerView: View { @State private var dragCurrentBrightness: CGFloat = 0 @State private var dragStartVolume: Float = 0 @State private var dragCurrentVolume: Float = 0 + @GestureState private var isPlayerDragGestureActive = false /// 长按 timer。finger 落下后启动;移动 > 12pt 或 finger 抬起时 cancel。 @State private var longPressTask: Task? /// 当前手势是否已经决定走 swipe 路径(以避免长按 timer 重复 schedule)。 @@ -133,7 +124,9 @@ struct KSPlayerView: View { /// which already shows the same bar via swipeHUD). @StateObject private var volumeObserver = SystemVolumeObserver() @State private var physicalVolumeHUDActive = false - @State private var physicalVolumeHUDHideTask: Task? + @State private var physicalVolumeHUDGeneration: UInt64 = 0 + @State private var lastHandledPhysicalVolumeTick: UInt64 = 0 + @State private var suppressPhysicalVolumeHUDUntil = Date.distantPast // MARK: - Buffering / loading feedback /// Observes the underlying AVPlayer's `timeControlStatus` — the @@ -178,35 +171,29 @@ struct KSPlayerView: View { init( snapshot: VideoDetailScreenSnapshot, isFullscreen: Binding, - isCollapsed: Binding, onProgress: @escaping (TimeInterval) -> Void = { _ in }, onPlaybackEnded: @escaping () -> Void = {}, onPlayingChanged: @escaping (Bool) -> Void = { _ in }, onControlsVisibilityChanged: @escaping (Bool) -> Void = { _ in }, onBack: @escaping () -> Void = {}, - isShrunken: Bool = false, - onRequestExpand: @escaping () -> Void = {}, + playRequestToken: Int = 0, onNaturalSize: @escaping (CGSize) -> Void = { _ in } ) { self.snapshot = snapshot self._isFullscreen = isFullscreen - self._isCollapsed = isCollapsed self.onProgress = onProgress self.onPlaybackEnded = onPlaybackEnded self.onPlayingChanged = onPlayingChanged self.onControlsVisibilityChanged = onControlsVisibilityChanged self.onBack = onBack - self.isShrunken = isShrunken - self.onRequestExpand = onRequestExpand + self.playRequestToken = playRequestToken self.onNaturalSize = onNaturalSize } var body: some View { let _ = Self.configureKSPlayerGlobalsOnce Group { - if isCollapsed { - collapsedStrip - } else if let activeSource = activeSource, + if let activeSource = activeSource, let url = URL(string: activeSource.url) { playerWithControls(url: url) } else { @@ -215,6 +202,9 @@ struct KSPlayerView: View { } .background(Color.black) .clipped() + .onValueChange(of: playRequestToken) { _ in + play() + } } // MARK: - Player + controls @@ -224,14 +214,12 @@ struct KSPlayerView: View { let resumeSeconds = TimeInterval(snapshot.playbackPositionMillis) / 1000 let options = makeKSOptions(resumeSeconds: resumeSeconds) - // GeometryReader wraps KSVideoPlayer (alone, not the whole ZStack) so the - // DragGesture handler can see the player's own size — needed to decide - // whether a vertical swipe started on the LEFT half (brightness) or the - // RIGHT half (volume), and to scale a horizontal swipe to a sensible - // seek delta. KSVideoPlayer is the only child of this GeometryReader, - // no branching, so view identity is preserved. - ZStack { - GeometryReader { proxy in + // GeometryReader gives both the video gestures and the controls overlay + // the player's actual size. Gestures need it for swipe classification; + // the overlay needs it for fullscreen safe-area padding. + GeometryReader { proxy in + let safeAreaInsets = isFullscreen ? proxy.safeAreaInsets : EdgeInsets() + ZStack { KSVideoPlayer(coordinator: coordinator, url: url, options: options) .onPlay { current, total in guard current.isFinite, current >= 0 else { return } @@ -274,9 +262,12 @@ struct KSPlayerView: View { // user rewind to 0 (or below ~2s) silently failed to // persist, so the saved resume position kept its // previous value across re-entry. - onProgress(current) + reportPlaybackProgress(current) + } + .onFinish { _, _ in + setPlayingState(false) + onPlaybackEnded() } - .onFinish { _, _ in onPlaybackEnded() } .onStateChanged { layer, state in // DIAGNOSTIC: the player previously swallowed every // state, so a failed open showed only a black screen @@ -308,14 +299,15 @@ struct KSPlayerView: View { AppLogger.log("autoplay enforced: \(autoPlayOnEnter ? "play" : "pause")") if autoPlayOnEnter { layer.play() + setPlayingState(true) } else { layer.pause() + setPlayingState(false) } - } - let nowPlaying = state.isPlaying - if nowPlaying != isPlaying { - isPlaying = nowPlaying - onPlayingChanged(nowPlaying) + } else if state.isPlaying { + setPlayingState(true) + } else if state == .error { + setPlayingState(false) } // Wire / re-wire the timeControlStatus observer // any time KSPlayer's state ticks, so we always have @@ -353,16 +345,6 @@ struct KSPlayerView: View { } .onTapGesture(count: 1) { if isBoosted { endBoost() } - if isShrunken { - // Player is currently shrunk by scroll. First - // tap restores it to 16:9 instead of opening the - // controls — avoids the visible layout pulse - // that would otherwise happen when the controls - // overlay tries to materialise on top of a - // mid-collapse-animation player. - onRequestExpand() - return - } withAnimation(.easeInOut(duration: 0.18)) { showsControls.toggle() } AppLogger.log("gesture: tap controls=\(showsControls ? "show" : "hide")") onControlsVisibilityChanged(showsControls) @@ -382,6 +364,7 @@ struct KSPlayerView: View { isPinching = true longPressTask?.cancel() longPressTask = nil + resetSwipeHUDState() if isBoosted { endBoost() } } .onEnded { value in @@ -415,6 +398,9 @@ struct KSPlayerView: View { // the tap / double-tap / pinch gestures. .simultaneousGesture( DragGesture(minimumDistance: 0) + .updating($isPlayerDragGestureActive) { _, isActive, _ in + isActive = true + } .onChanged { value in handlePressOrSwipe(value, in: proxy.size) } @@ -422,34 +408,36 @@ struct KSPlayerView: View { handlePressOrSwipeEnded() } ) - } - // Z-order: KSVideoPlayer < controlsOverlay < swipeHUD / boostHint. - // The two HUDs sit ABOVE the controls so the centre play / skip - // buttons (which live inside controlsOverlay) don't visually - // cover the swipe HUD or the boost badge. - if showsControls { - controlsOverlay.transition(.opacity) - } + // Z-order: KSVideoPlayer < controlsOverlay < swipeHUD / boostHint. + // The two HUDs sit ABOVE the controls so the centre play / skip + // buttons (which live inside controlsOverlay) don't visually + // cover the swipe HUD or the boost badge. + if showsControls { + controlsOverlay(safeAreaInsets: safeAreaInsets).transition(.opacity) + } - if dragState != .none { - swipeHUD.transition(.opacity) - } + if dragState != .none { + swipeHUD.transition(.opacity) + } - if isBoosted { - boostHint.transition(.opacity) - } + if isBoosted { + boostHint.transition(.opacity) + } - if statusObserver.isWaitingForPlayback { - loadingHUD.transition(.opacity) - } + if statusObserver.isWaitingForPlayback { + loadingHUD.transition(.opacity) + } - if physicalVolumeHUDActive { - physicalVolumeHUD.transition(.opacity) + if physicalVolumeHUDActive { + physicalVolumeHUD.transition(.opacity) + } } } .onAppear { scheduleAutoHide() + physicalVolumeHUDActive = false + resetSwipeHUDState() // Mount the hidden MPVolumeView so swipe-volume can write the // system output volume. Released on disappear so iOS's own // volume HUD works everywhere else in the app. @@ -463,19 +451,22 @@ struct KSPlayerView: View { lastLoadedEnd = 0 lastSampleAt = nil currentSpeedText = nil + lastProgressReportSeconds = nil + lastProgressReportAt = Date.distantPast // Bind the status observer eagerly if a layer already exists // (re-appear after navigation pop); fresh mounts will pick // it up via the onStateChanged callback once the layer is // created. statusObserver.observe(Self.findAVPlayer(in: coordinator.playerLayer?.player)) - AppLogger.log("player mount autoPlayOnEnter=\(autoPlayOnEnter) ksAutoPlay=\(KSOptions.isAutoPlay)") + AppLogger.log("player mount autoPlayOnEnter=\(autoPlayOnEnter) ksLoadAutoPlay=\(KSOptions.isAutoPlay)") } .onDisappear { hideControlsTask?.cancel() SystemVolumeController.release() volumeObserver.stop() - physicalVolumeHUDHideTask?.cancel() - physicalVolumeHUDHideTask = nil + physicalVolumeHUDGeneration &+= 1 + physicalVolumeHUDActive = false + resetSwipeHUDState() speedSampleTask?.cancel() speedSampleTask = nil // Pause the player when this view is no longer on-screen. @@ -484,18 +475,15 @@ struct KSPlayerView: View { // navigation stack — without an explicit pause, BOTH videos // would keep playing audio simultaneously. coordinator.playerLayer?.pause() + setPlayingState(false) } - .onValueChange(of: isShrunken) { newValue in - // The moment the parent reports the player has begun shrinking - // (paused user starts scrolling content up), hide the controls - // overlay. Otherwise the HUD would persist over a steadily - // shrinking player and look stuck / out-of-sync. - guard newValue, showsControls else { return } - withAnimation(.easeInOut(duration: 0.18)) { - showsControls = false + .onValueChange(of: isPlayerDragGestureActive) { isActive in + guard !isActive else { return } + let shouldResumeAutoHide = hasMovedToSwipe || dragState != .none || isBoosted + resetSwipeHUDState() + if shouldResumeAutoHide { + scheduleAutoHide() } - onControlsVisibilityChanged(false) - hideControlsTask?.cancel() } .onValueChange(of: statusObserver.isWaitingForPlayback) { waiting in // Speed sampler only runs while the player is genuinely waiting @@ -511,20 +499,31 @@ struct KSPlayerView: View { currentSpeedText = nil } } - .onReceive(volumeObserver.$changeTick) { _ in + .onReceive(volumeObserver.$changeTick) { tick in + // @Published emits its current value immediately on subscription. + // Tick 0 is not a hardware-key event; showing HUD for it can keep + // recreating the hide timer during normal SwiftUI re-subscription. + guard tick > 0 else { return } + guard tick != lastHandledPhysicalVolumeTick else { return } + lastHandledPhysicalVolumeTick = tick // Skip if the change came from our swipe-volume gesture — // swipeHUD is already visible for that case. Otherwise pop // the physical-key HUD and auto-hide after 1.5s. guard dragState != .volume else { return } - physicalVolumeHUDActive = true - physicalVolumeHUDHideTask?.cancel() - physicalVolumeHUDHideTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 1_500_000_000) - if !Task.isCancelled { - withAnimation(.easeInOut(duration: 0.18)) { - physicalVolumeHUDActive = false - } - } + guard Date() >= suppressPhysicalVolumeHUDUntil else { return } + showPhysicalVolumeHUD() + } + } + + private func showPhysicalVolumeHUD() { + physicalVolumeHUDGeneration &+= 1 + let generation = physicalVolumeHUDGeneration + physicalVolumeHUDActive = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard generation == physicalVolumeHUDGeneration else { return } + withAnimation(.easeInOut(duration: 0.18)) { + physicalVolumeHUDActive = false } } } @@ -659,7 +658,7 @@ struct KSPlayerView: View { return String(format: "%.0f B/s", bytesPerSec) } - private var controlsOverlay: some View { + private func controlsOverlay(safeAreaInsets: EdgeInsets) -> some View { ZStack { LinearGradient( colors: [.black.opacity(0.5), .clear, .black.opacity(0.55)], @@ -673,8 +672,10 @@ struct KSPlayerView: View { Spacer() bottomBar } - .padding(.horizontal, 12) - .padding(.vertical, 10) + .padding(.leading, 12 + safeAreaInsets.leading) + .padding(.trailing, 12 + safeAreaInsets.trailing) + .padding(.top, 10 + safeAreaInsets.top) + .padding(.bottom, 10 + safeAreaInsets.bottom) .foregroundStyle(.white) } // Pin to the player ZStack's full extent. Without this, the @@ -721,12 +722,6 @@ struct KSPlayerView: View { ) { coordinator.isMuted.toggle() } - // 收起按钮(暂停 + 非全屏时显示,跟 mute 等并排) - if !isFullscreen, !isPlaying { - iconButton(systemImage: "chevron.up", label: "收起播放器") { - withAnimation(.easeInOut(duration: 0.25)) { isCollapsed = true } - } - } // 比例 fit/fill iconButton( systemImage: coordinator.isScaleAspectFill @@ -738,11 +733,19 @@ struct KSPlayerView: View { } // Fullscreen toggle has been moved into bottomBar (right of the // playback-rate menu) — see `bottomBar`. Keeping the cluster - // {mute, collapse, aspect} on the right of topBar. + // {mute, aspect} on the right of topBar. } } private var bottomBar: some View { + GeometryReader { proxy in + bottomBarContent(showsTimeLabels: proxy.size.width >= 390) + .frame(width: proxy.size.width, height: proxy.size.height) + } + .frame(height: 44) + } + + private func bottomBarContent(showsTimeLabels: Bool) -> some View { let total = max(TimeInterval(coordinator.timemodel.totalTime), 1) // Slider value is now a PLAIN @State (not a closure-based binding) so // SwiftUI's first drag delta correctly persists into binding source on @@ -763,9 +766,12 @@ struct KSPlayerView: View { scheduleAutoHide() } - Text(Self.formatTime(sliderValue)) - .font(.caption2.monospacedDigit()) - .foregroundStyle(.white) + if showsTimeLabels { + Text(Self.formatTime(sliderValue)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.white) + .fixedSize(horizontal: true, vertical: false) + } Slider( value: $sliderValue, @@ -798,21 +804,28 @@ struct KSPlayerView: View { sliderValue = asTime } } + .layoutPriority(1) - Text(Self.formatTime(TimeInterval(coordinator.timemodel.totalTime))) - .font(.caption2.monospacedDigit()) - .foregroundStyle(.white) - - // 倍速 menu - playbackRateMenu + if showsTimeLabels { + Text(Self.formatTime(TimeInterval(coordinator.timemodel.totalTime))) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.white) + .fixedSize(horizontal: true, vertical: false) + } - // 画质 menu — only shown if the snapshot exposes more than one - // source (typical: the page only ships a single 'auto' source - // because the script extraction returns one URL). When more - // qualities exist they sit between the rate menu and the - // fullscreen toggle as the user requested. - if snapshot.playbackSources.count > 1 { - qualityMenu + if isFullscreen { + // Keep inline chrome compact; these menus are available + // once fullscreen has enough horizontal room. + playbackRateMenu + + // 画质 menu — only shown if the snapshot exposes more than one + // source (typical: the page only ships a single 'auto' source + // because the script extraction returns one URL). When more + // qualities exist they sit between the rate menu and the + // fullscreen toggle as the user requested. + if snapshot.playbackSources.count > 1 { + qualityMenu + } } // 全屏 toggle — placed immediately to the right of the playback @@ -860,7 +873,7 @@ struct KSPlayerView: View { Menu { ForEach(snapshot.playbackSources) { source in Button { - selectedSourceID = source.id + selectPlaybackSource(id: source.id) } label: { HStack { Text(source.label) @@ -997,28 +1010,6 @@ struct KSPlayerView: View { } } - // MARK: - Collapsed strip - - private var collapsedStrip: some View { - HStack(spacing: 10) { - Image(systemName: "play.rectangle.fill") - .foregroundStyle(.white) - .font(.title2) - Text(snapshot.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) - .lineLimit(1) - Spacer() - iconButton(systemImage: "chevron.down", label: "展开播放器") { - withAnimation(.easeInOut(duration: 0.25)) { isCollapsed = false } - } - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background(Color.black) - } - // MARK: - Helpers private func primarySource() -> VideoPlaybackSourceRow? { @@ -1037,6 +1028,48 @@ struct KSPlayerView: View { return primarySource() } + private func reportPlaybackProgress(_ current: TimeInterval) { + let now = Date() + let shouldReportImmediately = current <= 2 + && (lastProgressReportSeconds.map { abs(current - $0) >= 0.5 } ?? true) + let movedEnough = lastProgressReportSeconds.map { abs(current - $0) >= 5 } ?? true + let waitedEnough = now.timeIntervalSince(lastProgressReportAt) >= 5 + guard shouldReportImmediately || movedEnough || waitedEnough else { return } + + lastProgressReportSeconds = current + lastProgressReportAt = now + onProgress(current) + } + + private func selectPlaybackSource(id: String) { + guard activeSource?.id != id else { return } + selectedSourceID = id + resetPlaybackSessionForSourceChange() + } + + private func resetPlaybackSessionForSourceChange() { + hideControlsTask?.cancel() + speedSampleTask?.cancel() + speedSampleTask = nil + currentSpeedText = nil + statusObserver.observe(nil) + hasReachedStartPlayTime = false + hasAppliedResumeSeek = false + naturalSizeReported = false + autoPlayApplied = false + stateLogBudget = 8 + lastLoadedEnd = 0 + lastSampleAt = nil + lastProgressReportSeconds = nil + lastProgressReportAt = Date.distantPast + sliderValue = 0 + isSliderEditing = false + resetSwipeHUDState() + coordinator.playerLayer?.pause() + setPlayingState(false) + scheduleAutoHide() + } + /// Unified down/move handler. Called from `DragGesture(minimumDistance: 0)` /// so we get an onChanged on every touch-down. First call starts a 0.4s /// long-press timer (=> startBoost); subsequent calls cancel that timer @@ -1173,6 +1206,7 @@ struct KSPlayerView: View { guard size.height > 0 else { return } let fraction = -Float(value.translation.height / size.height) dragCurrentVolume = max(0, min(1, dragStartVolume + fraction)) + suppressPhysicalVolumeHUDUntil = Date().addingTimeInterval(0.8) SystemVolumeController.setVolume(dragCurrentVolume) case .none: break @@ -1194,10 +1228,43 @@ struct KSPlayerView: View { scheduleAutoHide() } + private func resetSwipeHUDState() { + longPressTask?.cancel() + longPressTask = nil + hasMovedToSwipe = false + if isBoosted { + endBoost() + } + if dragState != .none { + withAnimation(.easeOut(duration: 0.18)) { + dragState = .none + } + } + } + private func togglePlayPause() { guard let layer = coordinator.playerLayer else { return } AppLogger.log("gesture: toggle play/pause was=\(isPlaying ? "playing" : "paused")") - if isPlaying { layer.pause() } else { layer.play() } + let shouldPlay = !isPlaying + if shouldPlay { + layer.play() + } else { + layer.pause() + } + setPlayingState(shouldPlay) + } + + private func play() { + guard let layer = coordinator.playerLayer else { return } + layer.play() + setPlayingState(true) + scheduleAutoHide() + } + + private func setPlayingState(_ nowPlaying: Bool) { + guard nowPlaying != isPlaying else { return } + isPlaying = nowPlaying + onPlayingChanged(nowPlaying) } private func startBoost() { @@ -1254,12 +1321,11 @@ struct KSPlayerView: View { private static let playbackRates: [Float] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] private func makeKSOptions(resumeSeconds: TimeInterval) -> KSOptions { - // KSOptions.isAutoPlay is a class-level static. Re-set it on every - // option build so the user's auto_play_on_enter preference takes - // effect for the very next video they open (without restarting - // the app). When OFF, the player loads the source but stays - // paused at frame 0 until the user taps the play/pause button. - KSOptions.isAutoPlay = autoPlayOnEnter + // KSPlayer uses this class-level switch to decide whether to begin + // opening/buffering the URL at all. Keep it ON for loading, then + // enforce the user's autoPlayOnEnter preference once the layer first + // reaches .bufferFinished in onStateChanged. + KSOptions.isAutoPlay = true let options = KSOptions() options.appendHeader([ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", diff --git a/iosApp/LocalVideoPlayerView.swift b/iosApp/LocalVideoPlayerView.swift index 17fedc6b..511a11b2 100644 --- a/iosApp/LocalVideoPlayerView.swift +++ b/iosApp/LocalVideoPlayerView.swift @@ -13,7 +13,6 @@ struct LocalVideoPlayerView: View { let fileURL: URL @State private var isFullscreen = false - @State private var isCollapsed = false @State private var videoNaturalSize: CGSize? /// Mirrors the streaming detail page's preference so portrait videos /// can stay portrait in fullscreen. @@ -36,7 +35,6 @@ struct LocalVideoPlayerView: View { KSPlayerView( snapshot: snapshot, isFullscreen: $isFullscreen, - isCollapsed: $isCollapsed, onProgress: { seconds in DownloadManager.shared.updatePlaybackPosition( videoCode: videoCode, diff --git a/iosApp/Localizable.xcstrings b/iosApp/Localizable.xcstrings index 72437eeb..f3d46e7e 100644 --- a/iosApp/Localizable.xcstrings +++ b/iosApp/Localizable.xcstrings @@ -6380,6 +6380,28 @@ } } } + }, + "comment.no_more": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No more comments" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有更多了" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有更多了" + } + } + } } }, "version": "1.0" diff --git a/iosApp/RelatedVideoComponents.swift b/iosApp/RelatedVideoComponents.swift index 132830c7..ce596054 100644 --- a/iosApp/RelatedVideoComponents.swift +++ b/iosApp/RelatedVideoComponents.swift @@ -8,6 +8,10 @@ struct HorizontalVideoSection: View { let videoFeature: VideoFeature let commentFeature: CommentFeature let showPlaying: Bool + let showsMetadataFooter: Bool + + @State private var selectedVideo: VideoRelatedRow? + @State private var isShowingVideoList = false var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -22,14 +26,8 @@ struct HorizontalVideoSection: View { } } Spacer() - NavigationLink { - RelatedVideoListView( - title: title, - videos: videos, - videoFeature: videoFeature, - commentFeature: commentFeature, - showPlaying: showPlaying - ) + TapOnlyControl { + isShowingVideoList = true } label: { Text("更多") .font(.caption.weight(.semibold)) @@ -39,16 +37,44 @@ struct HorizontalVideoSection: View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack(alignment: .top, spacing: 12) { ForEach(videos) { video in - NavigationLink { - VideoDetailView(videoCode: video.videoCode, videoFeature: videoFeature, commentFeature: commentFeature) + ManualNavigationCard { + selectedVideo = video } label: { - RelatedVideoCard(video: video, showPlaying: showPlaying) + RelatedVideoCard( + video: video, + showPlaying: showPlaying, + showsMetadataFooter: showsMetadataFooter, + width: 172 + ) } - .buttonStyle(.plain) } } } } + .navigationDestination(isPresented: $isShowingVideoList) { + RelatedVideoListView( + title: title, + videos: videos, + videoFeature: videoFeature, + commentFeature: commentFeature, + showPlaying: showPlaying, + showsMetadataFooter: showsMetadataFooter + ) + } + .navigationDestination( + isPresented: Binding( + get: { selectedVideo != nil }, + set: { if !$0 { selectedVideo = nil } } + ) + ) { + if let selectedVideo { + VideoDetailView( + videoCode: selectedVideo.videoCode, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + } + } } } @@ -58,6 +84,7 @@ struct RelatedVideoListView: View { let videoFeature: VideoFeature let commentFeature: CommentFeature let showPlaying: Bool + let showsMetadataFooter: Bool var body: some View { ScrollView { @@ -74,7 +101,11 @@ struct RelatedVideoListView: View { commentFeature: commentFeature ) } label: { - RelatedVideoCard(video: video, showPlaying: showPlaying) + RelatedVideoCard( + video: video, + showPlaying: showPlaying, + showsMetadataFooter: showsMetadataFooter + ) } .buttonStyle(.plain) } @@ -92,6 +123,8 @@ struct RelatedVideoGrid: View { let videoFeature: VideoFeature let commentFeature: CommentFeature + @State private var selectedVideo: VideoRelatedRow? + var body: some View { VStack(alignment: .leading, spacing: 10) { Text("相关影片") @@ -99,15 +132,44 @@ struct RelatedVideoGrid: View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 156), spacing: 12)], spacing: 12) { ForEach(videos) { video in - NavigationLink { - VideoDetailView(videoCode: video.videoCode, videoFeature: videoFeature, commentFeature: commentFeature) + ManualNavigationCard { + selectedVideo = video } label: { RelatedVideoCard(video: video, showPlaying: false) } - .buttonStyle(.plain) } } } + .navigationDestination( + isPresented: Binding( + get: { selectedVideo != nil }, + set: { if !$0 { selectedVideo = nil } } + ) + ) { + if let selectedVideo { + VideoDetailView( + videoCode: selectedVideo.videoCode, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + } + } + } +} + +private struct ManualNavigationCard: View { + let action: () -> Void + let label: () -> Label + + init(action: @escaping () -> Void, @ViewBuilder label: @escaping () -> Label) { + self.action = action + self.label = label + } + + var body: some View { + label() + .contentShape(Rectangle()) + .onTapGesture(perform: action) } } @@ -188,17 +250,75 @@ struct TabletRelatedVideoRow: View { struct RelatedVideoCard: View { let video: VideoRelatedRow let showPlaying: Bool + let showsMetadataFooter: Bool + let width: CGFloat? + @State private var coverNaturalSize: CGSize? + + init( + video: VideoRelatedRow, + showPlaying: Bool, + showsMetadataFooter: Bool = true, + width: CGFloat? = nil + ) { + self.video = video + self.showPlaying = showPlaying + self.showsMetadataFooter = showsMetadataFooter + self.width = width + } var body: some View { VStack(alignment: .leading, spacing: 6) { - ZStack(alignment: .bottomLeading) { - CachedRemoteImage(urlString: video.coverUrl, resizeWidth: 172) - .frame(height: 96) - .clipped() + ZStack(alignment: .bottom) { + Color(.secondarySystemBackground) + .opacity(0.5) + .aspectRatio(16.0 / 9.0, contentMode: .fit) + .overlay { + CachedRemoteImage( + urlString: video.coverUrl, + contentMode: coverContentMode, + resizeWidth: 240, + onImageLoaded: { size in + coverNaturalSize = size + } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } + + LinearGradient( + colors: [ + .clear, + Color(.secondarySystemBackground).opacity(0.94) + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 36) + HStack(spacing: 5) { + if let views = video.views, !views.isEmpty { + Label(views, systemImage: "play.circle") + .labelStyle(.titleAndIcon) + } + + Spacer(minLength: 8) + + if let duration = video.duration, !duration.isEmpty { + Label(duration, systemImage: "clock") + .labelStyle(.titleAndIcon) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.horizontal, 7) + .padding(.bottom, 5) + } + .overlay(alignment: .topLeading) { if showPlaying && video.isPlaying { Text("正在播放") .font(.caption2.weight(.bold)) + .foregroundStyle(.primary) .padding(.horizontal, 8) .padding(.vertical, 4) .background(.regularMaterial, in: Capsule()) @@ -208,18 +328,58 @@ struct RelatedVideoCard: View { .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) Text(video.title) + .lineLimit(2) .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) - .lineLimit(2) + .frame(maxWidth: .infinity, minHeight: 40, maxHeight: 40, alignment: .topLeading) - if !video.metadata.isEmpty { - Text(video.metadata) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) + if showsMetadataFooter { + HStack(spacing: 6) { + MarqueeText(text: video.artistLabel) + if let uploadTime = video.uploadTime, !uploadTime.isEmpty { + Text(uploadTime) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .layoutPriority(1) + } + } + .frame(height: 16) } } - .frame(width: 172, alignment: .leading) + .relatedVideoCardWidth(width) + .padding(8) + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private var coverContentMode: ContentMode { + guard let coverNaturalSize, + coverNaturalSize.width > 0, + coverNaturalSize.height > coverNaturalSize.width * 1.1 else { + return .fill + } + return .fit + } +} + +private extension VideoRelatedRow { + var artistLabel: String { + guard let artist, !artist.isEmpty else { + return String(localized: "common.artist") + } + return artist + } +} + +private extension View { + @ViewBuilder + func relatedVideoCardWidth(_ width: CGFloat?) -> some View { + if let width { + frame(width: width, alignment: .leading) + } else { + frame(maxWidth: .infinity, alignment: .leading) + } } } diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift new file mode 100644 index 00000000..183dbec2 --- /dev/null +++ b/iosApp/VideoDetailPager.swift @@ -0,0 +1,1629 @@ +import SwiftUI +import UIKit + +enum VideoPlayerCollapseModel { + static func nextCollapseOffset( + currentCollapseOffset: CGFloat, + activeTabOffset: CGFloat, + previousActiveTabOffset: CGFloat?, + collapseDistance: CGFloat + ) -> CGFloat { + let clampedCurrent = clamp(currentCollapseOffset, upperBound: collapseDistance) + + guard let previousActiveTabOffset else { + let nonnegativeActiveOffset = max(0, activeTabOffset) + return min(collapseDistance, max(clampedCurrent, nonnegativeActiveOffset)) + } + + let delta = activeTabOffset - previousActiveTabOffset + if abs(delta) > 0.5 { + return clamp(clampedCurrent + delta, upperBound: collapseDistance) + } + + return clampedCurrent + } + + private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { + min(max(value, 0), max(upperBound, 0)) + } +} + +enum VideoDetailPagerOffsetModel { + static func initialNormalizedOffsetY( + visualTopOffset: CGFloat, + collapseDistance: CGFloat + ) -> CGFloat { + clamp(visualTopOffset, upperBound: collapseDistance) + } + + static func minimumContentHeight( + scrollBoundsHeight: CGFloat, + pinnedVisibleHeight: CGFloat, + collapseDistance: CGFloat + ) -> CGFloat { + max( + scrollBoundsHeight - max(pinnedVisibleHeight, 0), + max(collapseDistance, 0) + 1, + 1 + ) + } + + private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { + min(max(value, 0), max(upperBound, 0)) + } +} + +private struct VideoDetailSmoothHeaderSyncState: Equatable { + let headerContainerY: CGFloat + let inactiveNormalizedOffsetY: CGFloat + + static func state( + activeOffset: CGFloat, + collapseDistance: CGFloat + ) -> VideoDetailSmoothHeaderSyncState { + let resolvedCollapseDistance = max(collapseDistance, 0) + if activeOffset < resolvedCollapseDistance { + let scrollingOffset = max(activeOffset, 0) + return VideoDetailSmoothHeaderSyncState( + headerContainerY: -scrollingOffset, + inactiveNormalizedOffsetY: scrollingOffset + ) + } + return VideoDetailSmoothHeaderSyncState( + headerContainerY: -resolvedCollapseDistance, + inactiveNormalizedOffsetY: resolvedCollapseDistance + ) + } +} + +struct VideoDetailPagerLayoutMetrics { + let collapseDistance: CGFloat + let playerScrollAway: CGFloat + let continuationProgress: CGFloat +} + +struct VideoDetailPagerState: Equatable { + var selectedTab: VideoPageTab = .introduction + var collapseOffset: CGFloat = 0 + var tabOffsets: [VideoPageTab: CGFloat] = [:] + var isPlayerPlaying: Bool = false + + func layoutMetrics( + containerHeight _: CGFloat, + rawCollapseDistance: CGFloat + ) -> VideoDetailPagerLayoutMetrics { + let collapseDistance = isPlayerPlaying ? 0 : max(rawCollapseDistance, 0) + let playerScrollAway = isPlayerPlaying ? 0 : Self.clamp(collapseOffset, upperBound: collapseDistance) + let fadeDistance: CGFloat = 72 + let continuationProgress: CGFloat + if rawCollapseDistance > 1 { + continuationProgress = min( + max((playerScrollAway - (rawCollapseDistance - fadeDistance)) / fadeDistance, 0), + 1 + ) + } else { + continuationProgress = 0 + } + + return VideoDetailPagerLayoutMetrics( + collapseDistance: collapseDistance, + playerScrollAway: playerScrollAway, + continuationProgress: continuationProgress + ) + } + + mutating func setPlayerPlaying(_ isPlaying: Bool) { + isPlayerPlaying = isPlaying + if isPlaying { + collapseOffset = 0 + } + } + + mutating func expandPlayer() { + collapseOffset = 0 + } + + mutating func selectTab(_ tab: VideoPageTab, collapseDistance: CGFloat) { + selectedTab = tab + clampCollapse(to: collapseDistance) + } + + mutating func updateTabOffset(_ tab: VideoPageTab, offset: CGFloat, collapseDistance: CGFloat) { + guard tab == selectedTab else { return } + guard !isPlayerPlaying else { + collapseOffset = 0 + return + } + let trackedOffset = Self.clamp(offset, upperBound: collapseDistance) + let previousActiveOffset = tabOffsets[tab] + let previousCollapseOffset = collapseOffset + let nextCollapseOffset = nextCollapseOffset( + activeTabOffset: trackedOffset, + previousActiveTabOffset: previousActiveOffset, + collapseDistance: collapseDistance + ) + guard previousActiveOffset != trackedOffset + || abs(previousCollapseOffset - nextCollapseOffset) > 0.5 else { + return + } + tabOffsets[tab] = trackedOffset + collapseOffset = nextCollapseOffset + } + + private func nextCollapseOffset( + activeTabOffset: CGFloat, + previousActiveTabOffset: CGFloat?, + collapseDistance: CGFloat + ) -> CGFloat { + VideoPlayerCollapseModel.nextCollapseOffset( + currentCollapseOffset: collapseOffset, + activeTabOffset: activeTabOffset, + previousActiveTabOffset: previousActiveTabOffset, + collapseDistance: collapseDistance + ) + } + + mutating func beginInteracting(with tab: VideoPageTab, collapseDistance: CGFloat) { + guard selectedTab != tab else { return } + selectTab(tab, collapseDistance: collapseDistance) + } + + mutating func handleTopPull(tab: VideoPageTab, delta: CGFloat, collapseDistance: CGFloat) { + guard tab == selectedTab, !isPlayerPlaying, delta > 0, collapseOffset > 0 else { return } + collapseOffset = Self.clamp(collapseOffset - delta, upperBound: collapseDistance) + } + + mutating func clampCollapse(to collapseDistance: CGFloat) { + collapseOffset = Self.clamp(collapseOffset, upperBound: collapseDistance) + } + + private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { + min(max(value, 0), max(upperBound, 0)) + } +} + +enum VideoPageTab: String, CaseIterable, Identifiable { + case introduction + case comments + + var id: String { rawValue } + + var pageIndex: Int { + switch self { + case .introduction: return 0 + case .comments: return 1 + } + } + + static func page(at index: Int) -> VideoPageTab { + index <= 0 ? .introduction : .comments + } + + var scrollCoordinateSpaceName: String { + "bottomScroll-\(rawValue)" + } + + var title: String { + switch self { + case .introduction: + return String(localized: "简介") + case .comments: + return String(localized: "评论") + } + } +} + +struct VideoDetailTabContentRevision: Equatable { + let introduction: Int + let comments: Int +} + +private struct VideoDetailSmoothHeaderGeometry: Equatable { + let headerHeight: CGFloat + let pinHeaderHeight: CGFloat + let collapseDistance: CGFloat + let visualTopOffset: CGFloat + let pinnedVisibleHeight: CGFloat + + var contentTopInset: CGFloat { + max(headerHeight, 0) + max(pinHeaderHeight, 0) + } + + var resolvedVisualTopOffset: CGFloat { + VideoDetailPagerOffsetModel.initialNormalizedOffsetY( + visualTopOffset: visualTopOffset, + collapseDistance: collapseDistance + ) + } + + var collapseSpacerHeight: CGFloat { + max(collapseDistance + 1, 1) + } + + func listOffsetContext( + in scrollBoundsHeight: CGFloat + ) -> VideoDetailListOffsetContext { + let visualTopOffset = resolvedVisualTopOffset + return VideoDetailListOffsetContext( + contentTopInset: contentTopInset, + initialNormalizedOffsetY: visualTopOffset, + collapseSpacerHeight: collapseSpacerHeight, + minimumContentHeight: minimumContentHeight(in: scrollBoundsHeight) + ) + } + + func smoothHeaderSyncState(activeOffset: CGFloat) -> VideoDetailSmoothHeaderSyncState { + VideoDetailSmoothHeaderSyncState.state( + activeOffset: activeOffset, + collapseDistance: collapseDistance + ) + } + + func minimumContentHeight(in scrollBoundsHeight: CGFloat) -> CGFloat { + VideoDetailPagerOffsetModel.minimumContentHeight( + scrollBoundsHeight: scrollBoundsHeight, + pinnedVisibleHeight: pinnedVisibleHeight, + collapseDistance: collapseDistance + ) + } + + func rawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat, in listScrollView: UIScrollView) -> CGFloat { + let inset = listScrollView.contentInset + let minOffsetY = -inset.top + let maxOffsetY = max(minOffsetY, listScrollView.contentSize.height - listScrollView.bounds.height + inset.bottom) + let rawOffsetY = offsetY - inset.top + return min(max(rawOffsetY, minOffsetY), maxOffsetY) + } + + func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat, in listScrollView: UIScrollView) -> CGFloat { + rawOffsetY + listScrollView.contentInset.top + } + +} + +private struct VideoDetailListOffsetContext: Equatable { + let contentTopInset: CGFloat + let initialNormalizedOffsetY: CGFloat + let collapseSpacerHeight: CGFloat + let minimumContentHeight: CGFloat +} + +private struct VideoDetailNativeScrollPage { + let listScrollView: UIScrollView + let attachScrollDelegate: (UIScrollViewDelegate?) -> Void + let update: () -> Void +} + +private enum VideoDetailTabPageContent { + case swiftUI(() -> AnyView) + case nativeScrollView(VideoDetailNativeScrollPage) +} + +private struct VideoDetailHorizontalPagerPosition: Equatable { + private(set) var selectedIndex = 0 + private(set) var isPagingActive = false + + mutating func setSelectedIndex(_ index: Int) { + selectedIndex = clamped(index) + } + + mutating func setPagingActive(_ isActive: Bool) -> Bool { + guard isPagingActive != isActive else { return false } + isPagingActive = isActive + return true + } + + mutating func settleHorizontalPaging(at index: Int) -> (previousSelectedIndex: Int, wasPagingActive: Bool) { + let previousSelectedIndex = selectedIndex + let wasPagingActive = isPagingActive + selectedIndex = clamped(index) + isPagingActive = false + return (previousSelectedIndex, wasPagingActive) + } + + private func clamped(_ index: Int) -> Int { + min(max(index, 0), VideoPageTab.allCases.count - 1) + } +} + +private struct VideoDetailTabPage { + let tab: VideoPageTab + let contentBottomPadding: CGFloat + let isSelected: Bool + let headerGeometry: VideoDetailSmoothHeaderGeometry + let contentUpdateRevision: Int + let onOffsetChange: (VideoPageTab, CGFloat) -> Void + let onInteractionBegan: (VideoPageTab) -> Void + let onTopPullDelta: (VideoPageTab, CGFloat) -> Void + let content: VideoDetailTabPageContent + + init( + tab: VideoPageTab, + contentBottomPadding: CGFloat, + isSelected: Bool, + headerGeometry: VideoDetailSmoothHeaderGeometry, + contentUpdateRevision: Int, + onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, + onInteractionBegan: @escaping (VideoPageTab) -> Void, + onTopPullDelta: @escaping (VideoPageTab, CGFloat) -> Void, + @ViewBuilder content: @escaping () -> Content + ) { + self.tab = tab + self.contentBottomPadding = contentBottomPadding + self.isSelected = isSelected + self.headerGeometry = headerGeometry + self.contentUpdateRevision = contentUpdateRevision + self.onOffsetChange = onOffsetChange + self.onInteractionBegan = onInteractionBegan + self.onTopPullDelta = onTopPullDelta + self.content = .swiftUI({ AnyView(content()) }) + } + + init( + tab: VideoPageTab, + contentBottomPadding: CGFloat, + isSelected: Bool, + headerGeometry: VideoDetailSmoothHeaderGeometry, + contentUpdateRevision: Int, + onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, + onInteractionBegan: @escaping (VideoPageTab) -> Void, + onTopPullDelta: @escaping (VideoPageTab, CGFloat) -> Void, + listScrollView: UIScrollView, + attachScrollDelegate: @escaping (UIScrollViewDelegate?) -> Void, + nativeUpdate: @escaping () -> Void + ) { + self.tab = tab + self.contentBottomPadding = contentBottomPadding + self.isSelected = isSelected + self.headerGeometry = headerGeometry + self.contentUpdateRevision = contentUpdateRevision + self.onOffsetChange = onOffsetChange + self.onInteractionBegan = onInteractionBegan + self.onTopPullDelta = onTopPullDelta + self.content = .nativeScrollView( + VideoDetailNativeScrollPage( + listScrollView: listScrollView, + attachScrollDelegate: attachScrollDelegate, + update: nativeUpdate + ) + ) + } + + var nativeListScrollView: UIScrollView? { + if case .nativeScrollView(let nativePage) = content { + return nativePage.listScrollView + } + return nil + } + + var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? { + if case .nativeScrollView(let nativePage) = content { + return nativePage.attachScrollDelegate + } + return nil + } + + func withSelection(_ isSelected: Bool) -> VideoDetailTabPage { + switch content { + case .swiftUI(let content): + VideoDetailTabPage( + tab: tab, + contentBottomPadding: contentBottomPadding, + isSelected: isSelected, + headerGeometry: headerGeometry, + contentUpdateRevision: contentUpdateRevision, + onOffsetChange: onOffsetChange, + onInteractionBegan: onInteractionBegan, + onTopPullDelta: onTopPullDelta, + content: { content() } + ) + case .nativeScrollView(let nativePage): + VideoDetailTabPage( + tab: tab, + contentBottomPadding: contentBottomPadding, + isSelected: isSelected, + headerGeometry: headerGeometry, + contentUpdateRevision: contentUpdateRevision, + onOffsetChange: onOffsetChange, + onInteractionBegan: onInteractionBegan, + onTopPullDelta: onTopPullDelta, + listScrollView: nativePage.listScrollView, + attachScrollDelegate: nativePage.attachScrollDelegate, + nativeUpdate: nativePage.update + ) + } + } +} + +struct VideoDetailPagerContainer: View { + @Binding var state: VideoDetailPagerState + let collapseDistance: CGFloat + let headerHeight: CGFloat + let pinHeaderHeight: CGFloat + let pinnedVisibleHeight: CGFloat + let playerScrollAway: CGFloat + let continuationProgress: CGFloat + let introductionContentBottomPadding: CGFloat + let commentsContentBottomPadding: CGFloat + let introductionContentRevision: Int + let commentsContentRevision: Int + let headerContentRevision: Int + let continuationHeader: () -> ContinuationHeader + let isContinuationHeaderInteractive: Bool + let introduction: () -> Introduction + let comments: () -> Comments + let nativeCommentsListScrollView: UIScrollView? + let nativeCommentsAttachScrollDelegate: ((UIScrollViewDelegate?) -> Void)? + let nativeCommentsUpdate: (() -> Void)? + + private var selectedTabBinding: Binding { + Binding( + get: { state.selectedTab }, + set: { newTab in + mutateState { $0.selectTab(newTab, collapseDistance: collapseDistance) } + } + ) + } + + var body: some View { + VideoDetailTabPager( + selectedTab: selectedTabBinding, + headerContentRevision: headerContentRevision, + continuationHeader: AnyView(continuationHeader()), + continuationProgress: continuationProgress, + isContinuationHeaderInteractive: isContinuationHeaderInteractive, + pinHeader: AnyView(pinHeader), + introduction: tabPage( + .introduction, + contentBottomPadding: introductionContentBottomPadding, + contentUpdateRevision: introductionContentRevision, + content: introduction + ), + comments: commentsPage + ) + .frame(maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .onValueChange(of: collapseDistance) { newValue in + mutateState { $0.clampCollapse(to: newValue) } + } + } + + private var pinHeader: some View { + Picker("Content", selection: selectedTabBinding) { + ForEach(VideoPageTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .frame(height: pinHeaderHeight) + .frame(maxWidth: .infinity) + .background(.background) + } + + private func tabPage( + _ tab: VideoPageTab, + contentBottomPadding: CGFloat, + contentUpdateRevision: Int, + @ViewBuilder content: @escaping () -> Content + ) -> VideoDetailTabPage { + VideoDetailTabPage( + tab: tab, + contentBottomPadding: contentBottomPadding, + isSelected: state.selectedTab == tab, + headerGeometry: VideoDetailSmoothHeaderGeometry( + headerHeight: headerHeight, + pinHeaderHeight: pinHeaderHeight, + collapseDistance: collapseDistance, + visualTopOffset: playerScrollAway, + pinnedVisibleHeight: pinnedVisibleHeight + ), + contentUpdateRevision: contentUpdateRevision, + onOffsetChange: { tab, offset in + mutateState { + $0.updateTabOffset(tab, offset: offset, collapseDistance: collapseDistance) + } + }, + onInteractionBegan: { tab in + mutateState { + $0.beginInteracting(with: tab, collapseDistance: collapseDistance) + } + }, + onTopPullDelta: { tab, delta in + mutateState { + $0.handleTopPull(tab: tab, delta: delta, collapseDistance: collapseDistance) + } + }, + content: content + ) + } + + private var commentsPage: VideoDetailTabPage { + let geometry = VideoDetailSmoothHeaderGeometry( + headerHeight: headerHeight, + pinHeaderHeight: pinHeaderHeight, + collapseDistance: collapseDistance, + visualTopOffset: playerScrollAway, + pinnedVisibleHeight: pinnedVisibleHeight + ) + if let nativeCommentsListScrollView, + let nativeCommentsAttachScrollDelegate, + let nativeCommentsUpdate { + return VideoDetailTabPage( + tab: .comments, + contentBottomPadding: commentsContentBottomPadding, + isSelected: state.selectedTab == .comments, + headerGeometry: geometry, + contentUpdateRevision: commentsContentRevision, + onOffsetChange: { tab, offset in + mutateState { + $0.updateTabOffset(tab, offset: offset, collapseDistance: collapseDistance) + } + }, + onInteractionBegan: { tab in + mutateState { + $0.beginInteracting(with: tab, collapseDistance: collapseDistance) + } + }, + onTopPullDelta: { tab, delta in + mutateState { + $0.handleTopPull(tab: tab, delta: delta, collapseDistance: collapseDistance) + } + }, + listScrollView: nativeCommentsListScrollView, + attachScrollDelegate: nativeCommentsAttachScrollDelegate, + nativeUpdate: nativeCommentsUpdate + ) + } + return tabPage( + .comments, + contentBottomPadding: commentsContentBottomPadding, + contentUpdateRevision: commentsContentRevision, + content: comments + ) + } + + private func mutateState(_ mutation: (inout VideoDetailPagerState) -> Void) { + withTransaction(Transaction(animation: nil)) { + var nextState = state + mutation(&nextState) + if nextState != state { + state = nextState + } + } + } +} + +private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScrollViewDelegate { + var tab: VideoPageTab + var onOffsetChange: (VideoPageTab, CGFloat) -> Void + var onInteractionBegan: (VideoPageTab) -> Void + var onTopPullDelta: (VideoPageTab, CGFloat) -> Void + var onVerticalInteractionBegan: () -> Void = {} + var onVisibleOffsetChange: (VideoPageTab, CGFloat) -> Void = { _, _ in } + var visualTopContentOffsetY: CGFloat = 0 + var isApplyingExternalOffset = false + private var lastReportedOffset: CGFloat? + private var lastTopPullTranslationY: CGFloat = 0 + + init( + tab: VideoPageTab, + onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, + onInteractionBegan: @escaping (VideoPageTab) -> Void, + onTopPullDelta: @escaping (VideoPageTab, CGFloat) -> Void + ) { + self.tab = tab + self.onOffsetChange = onOffsetChange + self.onInteractionBegan = onInteractionBegan + self.onTopPullDelta = onTopPullDelta + } + + func scrollViewDidScroll(_ listScrollView: UIScrollView) { + guard !isApplyingExternalOffset else { return } + let offset = listScrollView.verticalContentOffsetExcludingBounce + onVisibleOffsetChange(tab, offset) + guard lastReportedOffset.map({ abs($0 - offset) > 0.5 }) ?? true else { return } + lastReportedOffset = offset + onOffsetChange(tab, offset) + } + + func resetReportedOffset(_ offset: CGFloat) { + lastReportedOffset = offset + } + + @objc func handlePan(_ panGestureRecognizer: UIPanGestureRecognizer) { + guard let listScrollView = panGestureRecognizer.view as? UIScrollView else { return } + switch panGestureRecognizer.state { + case .began: + onVerticalInteractionBegan() + onInteractionBegan(tab) + lastTopPullTranslationY = 0 + case .changed: + guard listScrollView.verticalContentOffsetExcludingBounce <= visualTopContentOffsetY + 0.5 else { + lastTopPullTranslationY = panGestureRecognizer.translation(in: listScrollView).y + return + } + let translationY = panGestureRecognizer.translation(in: listScrollView).y + let delta = translationY - lastTopPullTranslationY + lastTopPullTranslationY = translationY + if delta > 0 { + onTopPullDelta(tab, delta) + } + default: + lastTopPullTranslationY = 0 + } + } +} + +private final class VideoDetailVerticalScrollPageViewController: UIViewController { + private let coordinator: VideoDetailVerticalScrollPageCoordinator + private let listScrollView: UIScrollView + private let defaultScrollView: VerticalScrollView? + private let usesNativeListScrollView: Bool + private let contentView = UIView() + private let listHeaderView = UIView() + private let collapseSpacerView = UIView() + private let contentBottomSpacerView = UIView() + private let host = UIHostingController(rootView: AnyView(EmptyView())) + private var hostMinimumHeightConstraint: NSLayoutConstraint? + private var contentMinimumHeightConstraint: NSLayoutConstraint? + private var collapseSpacerHeightConstraint: NSLayoutConstraint? + private var contentBottomSpacerHeightConstraint: NSLayoutConstraint? + private var contentUpdateRevision: Int? + private var nativeUpdateRevision: Int? + private var nativeContentBottomPadding: CGFloat? + private var lastAppliedPage: VideoDetailTabPage? + private var didApplyInitialContentOffset = false + private var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? + var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } + + init(tab: VideoPageTab, listScrollView: UIScrollView? = nil) { + let resolvedScrollView = listScrollView ?? VerticalScrollView() + self.listScrollView = resolvedScrollView + self.defaultScrollView = resolvedScrollView as? VerticalScrollView + self.usesNativeListScrollView = listScrollView != nil + coordinator = VideoDetailVerticalScrollPageCoordinator( + tab: tab, + onOffsetChange: { _, _ in }, + onInteractionBegan: { _ in }, + onTopPullDelta: { _, _ in } + ) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + nativeScrollDelegateAttachment?(nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + + listScrollView.translatesAutoresizingMaskIntoConstraints = false + listScrollView.backgroundColor = .clear + listScrollView.bounces = true + listScrollView.alwaysBounceVertical = true + listScrollView.alwaysBounceHorizontal = false + listScrollView.showsHorizontalScrollIndicator = false + listScrollView.showsVerticalScrollIndicator = false + listScrollView.isDirectionalLockEnabled = true + listScrollView.contentInsetAdjustmentBehavior = .never + listScrollView.keyboardDismissMode = .interactive + if defaultScrollView != nil { + listScrollView.delegate = coordinator + } + listScrollView.panGestureRecognizer.addTarget(coordinator, action: #selector(VideoDetailVerticalScrollPageCoordinator.handlePan(_:))) + defaultScrollView?.onGeometryChange = { [weak self] in + self?.applySwiftUIContentMinimumHeight() + } + defaultScrollView?.shouldBeginVerticalPan = { [weak self] panGestureRecognizer, view in + self?.handleVerticalInteractionBegan() + let velocity = panGestureRecognizer.velocity(in: view) + return abs(velocity.x) <= abs(velocity.y) * 1.05 + } + view.addSubview(listScrollView) + + listHeaderView.backgroundColor = .clear + listHeaderView.isUserInteractionEnabled = true + listScrollView.addSubview(listHeaderView) + + var constraints = [ + listScrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + listScrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + listScrollView.topAnchor.constraint(equalTo: view.topAnchor), + listScrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ] + + if !usesNativeListScrollView { + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.backgroundColor = .clear + listScrollView.addSubview(contentView) + + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + host.view.backgroundColor = .clear + contentView.addSubview(host.view) + host.didMove(toParent: self) + + collapseSpacerView.translatesAutoresizingMaskIntoConstraints = false + collapseSpacerView.backgroundColor = .clear + contentView.addSubview(collapseSpacerView) + + contentBottomSpacerView.translatesAutoresizingMaskIntoConstraints = false + contentBottomSpacerView.backgroundColor = .clear + contentView.addSubview(contentBottomSpacerView) + + let hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: listScrollView.frameLayoutGuide.heightAnchor) + let contentMinimumHeightConstraint = contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 1) + let collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) + let contentBottomSpacerHeightConstraint = contentBottomSpacerView.heightAnchor.constraint(equalToConstant: 0) + self.hostMinimumHeightConstraint = hostMinimumHeightConstraint + self.contentMinimumHeightConstraint = contentMinimumHeightConstraint + self.collapseSpacerHeightConstraint = collapseSpacerHeightConstraint + self.contentBottomSpacerHeightConstraint = contentBottomSpacerHeightConstraint + + constraints.append(contentsOf: [ + contentView.leadingAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.trailingAnchor), + contentView.topAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: listScrollView.frameLayoutGuide.widthAnchor), + contentMinimumHeightConstraint, + + host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + host.view.topAnchor.constraint(equalTo: contentView.topAnchor), + hostMinimumHeightConstraint, + + collapseSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collapseSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + collapseSpacerView.topAnchor.constraint(equalTo: host.view.bottomAnchor), + collapseSpacerHeightConstraint, + + contentBottomSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + contentBottomSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + contentBottomSpacerView.topAnchor.constraint(equalTo: collapseSpacerView.bottomAnchor), + contentBottomSpacerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + contentBottomSpacerHeightConstraint + ]) + } + NSLayoutConstraint.activate(constraints) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + applyListHeaderFrame() + applySwiftUIContentMinimumHeight() + } + + func update(page: VideoDetailTabPage) { + loadViewIfNeeded() + coordinator.tab = page.tab + coordinator.onOffsetChange = page.onOffsetChange + coordinator.onInteractionBegan = page.onInteractionBegan + coordinator.onTopPullDelta = page.onTopPullDelta + coordinator.onVerticalInteractionBegan = { [weak self] in + self?.handleVerticalInteractionBegan() + } + coordinator.onVisibleOffsetChange = { [weak self] tab, offset in + self?.onHeaderOffsetChanged(tab, offset) + } + switch page.content { + case .nativeScrollView(let nativePage): + if nativeScrollDelegateAttachment == nil { + nativeScrollDelegateAttachment = nativePage.attachScrollDelegate + nativePage.attachScrollDelegate(coordinator) + } + case .swiftUI: + break + } + if contentUpdateRevision != page.contentUpdateRevision { + contentUpdateRevision = page.contentUpdateRevision + switch page.content { + case .swiftUI(let content): + nativeUpdateRevision = nil + nativeContentBottomPadding = nil + host.view.isHidden = false + host.rootView = content() + case .nativeScrollView: + if !usesNativeListScrollView { + host.view.isHidden = true + host.rootView = AnyView(EmptyView()) + } + } + view.setNeedsLayout() + view.layoutIfNeeded() + } + + let geometry = page.headerGeometry + let offsetContext = geometry.listOffsetContext(in: listScrollView.bounds.height) + let visualTopOffset = offsetContext.initialNormalizedOffsetY + lastAppliedPage = page + coordinator.visualTopContentOffsetY = visualTopOffset + applyTopContentInset(offsetContext.contentTopInset) + applyListHeaderFrame() + switch page.content { + case .swiftUI: + applyBottomContentSpacing(page.contentBottomPadding, usesContentSpacer: true) + collapseSpacerHeightConstraint?.constant = offsetContext.collapseSpacerHeight + contentMinimumHeightConstraint?.constant = offsetContext.minimumContentHeight + hostMinimumHeightConstraint?.isActive = true + case .nativeScrollView: + collapseSpacerHeightConstraint?.constant = 0 + contentMinimumHeightConstraint?.constant = 1 + hostMinimumHeightConstraint?.isActive = false + if case .nativeScrollView(let nativePage) = page.content { + applyNativeUpdateIfNeeded(nativePage, page: page) + applyInitialContentOffsetIfNeeded(offsetContext.initialNormalizedOffsetY, isSelected: page.isSelected) + } + } + } + + private func applyNativeUpdateIfNeeded(_ nativePage: VideoDetailNativeScrollPage, page: VideoDetailTabPage) { + let shouldUpdate = nativeUpdateRevision != page.contentUpdateRevision + || nativeContentBottomPadding.map { abs($0 - page.contentBottomPadding) > 0.5 } ?? true + guard shouldUpdate else { return } + nativeUpdateRevision = page.contentUpdateRevision + nativeContentBottomPadding = page.contentBottomPadding + nativePage.update() + } + + private func applySwiftUIContentMinimumHeight() { + guard let page = lastAppliedPage else { return } + guard case .swiftUI = page.content else { return } + let offsetContext = page.headerGeometry.listOffsetContext(in: listScrollView.bounds.height) + if let contentMinimumHeightConstraint, + abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { + contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight + } + } + + private func applyTopContentInset(_ topInset: CGFloat) { + let resolvedTopInset = max(topInset, 0) + guard abs(listScrollView.contentInset.top - resolvedTopInset) > 0.5 else { return } + listScrollView.contentInset.top = resolvedTopInset + listScrollView.verticalScrollIndicatorInsets.top = resolvedTopInset + applyListHeaderFrame() + } + + private func applyBottomContentSpacing(_ bottomSpacing: CGFloat, usesContentSpacer: Bool) { + let resolvedBottomSpacing = max(bottomSpacing, 0) + let contentSpacerHeight = usesContentSpacer ? resolvedBottomSpacing : 0 + if let contentBottomSpacerHeightConstraint, + abs(contentBottomSpacerHeightConstraint.constant - contentSpacerHeight) > 0.5 { + contentBottomSpacerHeightConstraint.constant = contentSpacerHeight + } + let bottomInset = usesContentSpacer ? 0 : resolvedBottomSpacing + if abs(listScrollView.contentInset.bottom - bottomInset) > 0.5 { + listScrollView.contentInset.bottom = bottomInset + clampOffsetAfterInsetChangeIfNeeded() + } + if abs(listScrollView.verticalScrollIndicatorInsets.bottom - resolvedBottomSpacing) > 0.5 { + listScrollView.verticalScrollIndicatorInsets.bottom = resolvedBottomSpacing + } + } + + private func clampOffsetAfterInsetChangeIfNeeded() { + guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } + let clampedRawOffsetY = listScrollView.clampedRawVerticalContentOffsetY(listScrollView.contentOffset.y) + setRawContentOffsetYSilentlyIfNeeded(clampedRawOffsetY) + } + + private func applyListHeaderFrame() { + let topInset = max(listScrollView.contentInset.top, 0) + let nextFrame = CGRect( + x: 0, + y: -topInset, + width: listScrollView.bounds.width, + height: topInset + ) + guard !listHeaderView.frame.isApproximatelyEqual(to: nextFrame) else { return } + listHeaderView.frame = nextFrame + } + + private func handleVerticalInteractionBegan() { + guard let page = lastAppliedPage else { return } + coordinator.visualTopContentOffsetY = page.headerGeometry.resolvedVisualTopOffset + } + + private func applyInitialContentOffsetIfNeeded(_ offsetY: CGFloat, isSelected: Bool) { + guard isSelected else { return } + guard !didApplyInitialContentOffset else { return } + guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } + guard setNormalizedContentOffsetYIfReachable(offsetY) else { return } + didApplyInitialContentOffset = true + } + + func applyInitialContentOffsetForSelectionIfNeeded() { + loadViewIfNeeded() + guard let page = lastAppliedPage else { return } + applyInitialContentOffsetIfNeeded( + page.headerGeometry.resolvedVisualTopOffset, + isSelected: page.isSelected + ) + } + + var normalizedContentOffsetY: CGFloat { + loadViewIfNeeded() + return listScrollView.verticalContentOffsetExcludingBounce + } + + @discardableResult + func syncHeaderOffsetFromActivePage(_ normalizedOffsetY: CGFloat) -> Bool { + loadViewIfNeeded() + guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { + return false + } + return setNormalizedContentOffsetYIfReachable(normalizedOffsetY) + } + + func setScrollsToTop(_ scrollsToTop: Bool) { + loadViewIfNeeded() + listScrollView.scrollsToTop = scrollsToTop + } + + func reportCurrentOffset() { + loadViewIfNeeded() + let offset = listScrollView.verticalContentOffsetExcludingBounce + coordinator.resetReportedOffset(offset) + coordinator.onOffsetChange(coordinator.tab, offset) + } + + @discardableResult + private func setNormalizedContentOffsetYIfReachable(_ offsetY: CGFloat) -> Bool { + let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: offsetY) + guard abs(normalizedContentOffsetY(forRawOffsetY: rawTopOffsetY) - offsetY) <= 0.5 else { + return false + } + setRawContentOffsetYSilentlyIfNeeded(rawTopOffsetY) + return true + } + + private func setRawContentOffsetYSilentlyIfNeeded(_ rawTopOffsetY: CGFloat) { + guard abs(listScrollView.contentOffset.y - rawTopOffsetY) > 0.5 else { return } + coordinator.isApplyingExternalOffset = true + defer { coordinator.isApplyingExternalOffset = false } + listScrollView.setContentOffset(CGPoint(x: listScrollView.contentOffset.x, y: rawTopOffsetY), animated: false) + coordinator.resetReportedOffset(listScrollView.verticalContentOffsetExcludingBounce) + } + + private func clampedRawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat) -> CGFloat { + guard let page = lastAppliedPage else { return 0 } + return page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: listScrollView) + } + + private func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat) -> CGFloat { + guard let page = lastAppliedPage else { return rawOffsetY + listScrollView.contentInset.top } + return page.headerGeometry.normalizedContentOffsetY(forRawOffsetY: rawOffsetY, in: listScrollView) + } + +} + +private final class VerticalScrollView: UIScrollView { + var shouldBeginVerticalPan: ((UIPanGestureRecognizer, UIView) -> Bool)? + var onGeometryChange: (() -> Void)? + + override var bounds: CGRect { + didSet { + guard abs(bounds.height - oldValue.height) > 0.5 + || abs(bounds.width - oldValue.width) > 0.5 else { return } + onGeometryChange?() + } + } + + override var contentSize: CGSize { + didSet { + guard abs(contentSize.height - oldValue.height) > 0.5 + || abs(contentSize.width - oldValue.width) > 0.5 else { return } + onGeometryChange?() + } + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, + panGestureRecognizer === self.panGestureRecognizer { + return shouldBeginVerticalPan?(panGestureRecognizer, self) ?? true + } + return super.gestureRecognizerShouldBegin(gestureRecognizer) + } +} + +private extension UIScrollView { + var verticalContentOffsetExcludingBounce: CGFloat { + clampedRawVerticalContentOffsetY(contentOffset.y) + contentInset.top + } + + func clampedRawVerticalContentOffsetY(_ rawOffsetY: CGFloat) -> CGFloat { + let inset = contentInset + let minOffsetY = -inset.top + let maxOffsetY = max(minOffsetY, contentSize.height - bounds.height + inset.bottom) + return min(max(rawOffsetY, minOffsetY), maxOffsetY) + } +} + +private extension CGRect { + func isApproximatelyEqual(to other: CGRect) -> Bool { + abs(origin.x - other.origin.x) <= 0.5 + && abs(origin.y - other.origin.y) <= 0.5 + && abs(size.width - other.size.width) <= 0.5 + && abs(size.height - other.size.height) <= 0.5 + } +} + +private struct VideoDetailTabPager: UIViewControllerRepresentable { + @Binding var selectedTab: VideoPageTab + let headerContentRevision: Int + let continuationHeader: AnyView + let continuationProgress: CGFloat + let isContinuationHeaderInteractive: Bool + let pinHeader: AnyView + let introduction: VideoDetailTabPage + let comments: VideoDetailTabPage + + init( + selectedTab: Binding, + headerContentRevision: Int, + continuationHeader: AnyView, + continuationProgress: CGFloat, + isContinuationHeaderInteractive: Bool, + pinHeader: AnyView, + introduction: VideoDetailTabPage, + comments: VideoDetailTabPage + ) { + _selectedTab = selectedTab + self.headerContentRevision = headerContentRevision + self.continuationHeader = continuationHeader + self.continuationProgress = continuationProgress + self.isContinuationHeaderInteractive = isContinuationHeaderInteractive + self.pinHeader = pinHeader + self.introduction = introduction + self.comments = comments + } + + func makeCoordinator() -> Coordinator { + Coordinator(selectedTab: $selectedTab) + } + + func makeUIViewController(context: Context) -> PagingViewController { + PagingViewController(coordinator: context.coordinator) + } + + func updateUIViewController(_ uiViewController: PagingViewController, context: Context) { + context.coordinator.selectedTab = $selectedTab + uiViewController.updatePages( + headerContentRevision: headerContentRevision, + continuationHeader: continuationHeader, + continuationProgress: continuationProgress, + isContinuationHeaderInteractive: isContinuationHeaderInteractive, + pinHeader: pinHeader, + introduction: introduction, + comments: comments, + selectedIndex: selectedTab.pageIndex, + animated: context.coordinator.shouldAnimateProgrammaticSelection(to: selectedTab.pageIndex) + ) + } + + final class Coordinator: NSObject, UIScrollViewDelegate { + var selectedTab: Binding + var onSelectedIndexSettled: ((Int) -> Void)? + var onPagingActivityChanged: ((Bool) -> Void)? + private var lastProgrammaticIndex: Int? + + init(selectedTab: Binding) { + self.selectedTab = selectedTab + } + + func shouldAnimateProgrammaticSelection(to index: Int) -> Bool { + defer { lastProgrammaticIndex = index } + guard let lastProgrammaticIndex else { return false } + return lastProgrammaticIndex != index + } + + func scrollViewWillBeginDragging(_ listScrollView: UIScrollView) { + onPagingActivityChanged?(true) + } + + func scrollViewDidScroll(_ listScrollView: UIScrollView) { + let width = listScrollView.bounds.width + guard width > 0 else { return } + guard abs(listScrollView.contentOffset.x - CGFloat(selectedTab.wrappedValue.pageIndex) * width) > 0.5 else { + return + } + onPagingActivityChanged?(true) + } + + func scrollViewDidEndDecelerating(_ listScrollView: UIScrollView) { + settleSelectedIndex(from: listScrollView) + onPagingActivityChanged?(false) + } + + func scrollViewDidEndScrollingAnimation(_ listScrollView: UIScrollView) { + settleSelectedIndex(from: listScrollView) + onPagingActivityChanged?(false) + } + + func scrollViewDidEndDragging(_ listScrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + settleSelectedIndex(from: listScrollView) + onPagingActivityChanged?(false) + } + } + + func shouldBeginHorizontalPagingPan( + panGestureRecognizer: UIPanGestureRecognizer, + in view: UIView + ) -> Bool { + let startLocation = panGestureRecognizer.location(in: view) + guard startLocation.x > 24 else { return false } + guard !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) else { + return false + } + + let translation = panGestureRecognizer.translation(in: view) + let velocity = panGestureRecognizer.velocity(in: view) + let horizontal = max(abs(translation.x), abs(velocity.x) * 0.05) + let vertical = max(abs(translation.y), abs(velocity.y) * 0.05) + return horizontal > 8 && horizontal > vertical * 1.18 + } + + private func settleSelectedIndex(from listScrollView: UIScrollView) { + let width = listScrollView.bounds.width + guard width > 0 else { return } + let index = Int(round(listScrollView.contentOffset.x / width)) + onSelectedIndexSettled?(index) + let tab = VideoPageTab.page(at: index) + if selectedTab.wrappedValue != tab { + selectedTab.wrappedValue = tab + } + } + } + + final class PagingViewController: UIViewController { + private let coordinator: Coordinator + private let scrollView = PagingScrollView() + private let contentView = UIView() + private let headerContainerView = PagingHeaderContainerView() + private let continuationHeaderHost = UIHostingController(rootView: AnyView(EmptyView())) + private let pinHeaderHost = UIHostingController(rootView: AnyView(EmptyView())) + private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) + private var commentsPage: VideoDetailVerticalScrollPageViewController? + private weak var commentsListScrollView: UIScrollView? + private var pagerPosition = VideoDetailHorizontalPagerPosition() + private var pendingSelectedIndex: Int? + private var lastLaidOutWidth: CGFloat = 0 + private var headerContentRevision: Int? + private var latestPages: [VideoPageTab: VideoDetailTabPage] = [:] + + init(coordinator: Coordinator) { + self.coordinator = coordinator + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.backgroundColor = .clear + scrollView.isPagingEnabled = true + scrollView.bounces = false + scrollView.alwaysBounceHorizontal = false + scrollView.alwaysBounceVertical = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.isDirectionalLockEnabled = true + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.delegate = coordinator + coordinator.onSelectedIndexSettled = { [weak self] index in + self?.settlePageAfterHorizontalSelection(index) + } + scrollView.shouldBeginPagingPan = { [weak self] panGestureRecognizer, view in + self?.coordinator.shouldBeginHorizontalPagingPan( + panGestureRecognizer: panGestureRecognizer, + in: view + ) ?? true + } + coordinator.onPagingActivityChanged = { [weak self] isActive in + self?.setHorizontalPagingActive(isActive) + } + view.addSubview(scrollView) + + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.backgroundColor = .clear + scrollView.addSubview(contentView) + addPage(introductionPage) + + headerContainerView.backgroundColor = .clear + headerContainerView.pinHeaderHeight = 48 + addChild(continuationHeaderHost) + continuationHeaderHost.view.backgroundColor = .clear + headerContainerView.addSubview(continuationHeaderHost.view) + continuationHeaderHost.didMove(toParent: self) + addChild(pinHeaderHost) + pinHeaderHost.view.backgroundColor = .clear + headerContainerView.addSubview(pinHeaderHost.view) + pinHeaderHost.didMove(toParent: self) + view.addSubview(headerContainerView) + + updateScrollsToTop(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) + introductionPage.onHeaderOffsetChanged = { [weak self] tab, offset in + self?.updateHeaderContainerPosition(for: tab, offsetY: offset) + } + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + + introductionPage.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + introductionPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), + introductionPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + introductionPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + introductionPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + ]) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let width = scrollView.bounds.width + let widthChanged = abs(width - lastLaidOutWidth) > 0.5 + lastLaidOutWidth = width + layoutHeaderHosts() + updateHeaderAttachmentForCurrentState() + if isHorizontalSelectionInProgress { + return + } + if let pendingSelectedIndex { + self.pendingSelectedIndex = nil + setSelectedIndex(pendingSelectedIndex, animated: false) + } else if widthChanged { + setSelectedIndex(pagerPosition.selectedIndex, animated: false) + } + } + + func updatePages( + headerContentRevision: Int, + continuationHeader: AnyView, + continuationProgress: CGFloat, + isContinuationHeaderInteractive: Bool, + pinHeader: AnyView, + introduction: VideoDetailTabPage, + comments: VideoDetailTabPage, + selectedIndex: Int, + animated: Bool + ) { + loadViewIfNeeded() + if isHorizontalSelectionInProgress { + pendingSelectedIndex = selectedIndex == pagerPosition.selectedIndex ? nil : selectedIndex + } else { + pagerPosition.setSelectedIndex(selectedIndex) + } + latestPages[.introduction] = introduction + latestPages[.comments] = comments + prepareCommentsPageIfNeeded(for: comments) + updateHeaderHosts( + headerContentRevision: headerContentRevision, + continuationHeader: continuationHeader, + continuationProgress: continuationProgress, + isContinuationHeaderInteractive: isContinuationHeaderInteractive, + pinHeader: pinHeader, + page: VideoPageTab.page(at: pagerPosition.selectedIndex) + ) + introductionPage.update(page: introduction) + commentsPage?.update(page: comments) + updateScrollsToTop(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) + updateHeaderAttachmentForCurrentState() + if !pagerPosition.isPagingActive { + syncInactivePageHeaderOffset() + } + guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } + setSelectedIndex(pagerPosition.selectedIndex, animated: animated) + } + + private func addPage(_ page: UIViewController) { + addChild(page) + page.view.translatesAutoresizingMaskIntoConstraints = false + page.view.backgroundColor = .clear + contentView.addSubview(page.view) + page.didMove(toParent: self) + } + + private func setSelectedIndex(_ index: Int, animated: Bool) { + pagerPosition.setSelectedIndex(index) + let width = scrollView.bounds.width + guard width > 0 else { + pendingSelectedIndex = pagerPosition.selectedIndex + return + } + let targetOffset = CGPoint(x: CGFloat(pagerPosition.selectedIndex) * width, y: 0) + guard abs(scrollView.contentOffset.x - targetOffset.x) > 0.5 else { return } + if animated { + setHorizontalPagingActive(true) + } + scrollView.setContentOffset(targetOffset, animated: animated) + } + + private var isHorizontalSelectionInProgress: Bool { + pagerPosition.isPagingActive + || scrollView.isTracking + || scrollView.isDragging + || scrollView.isDecelerating + } + + private func settlePageAfterHorizontalSelection(_ index: Int) { + let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + finishHorizontalPaging(at: clampedIndex) + } + + private func finishHorizontalPaging(at index: Int) { + let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + let settlement = pagerPosition.settleHorizontalPaging(at: clampedIndex) + guard clampedIndex != settlement.previousSelectedIndex || settlement.wasPagingActive else { + pendingSelectedIndex = nil + return + } + pendingSelectedIndex = nil + refreshPageSelectionSnapshots(selectedTab: VideoPageTab.page(at: clampedIndex)) + updateScrollsToTop(for: VideoPageTab.page(at: clampedIndex)) + layoutHeaderHosts() + let selectedPage = verticalPageController(for: VideoPageTab.page(at: clampedIndex)) + selectedPage?.applyInitialContentOffsetForSelectionIfNeeded() + selectedPage?.reportCurrentOffset() + syncInactivePageHeaderOffset() + updateHeaderAttachmentForCurrentState() + } + + private func refreshPageSelectionSnapshots(selectedTab: VideoPageTab) { + for tab in VideoPageTab.allCases { + guard let page = latestPages[tab] else { continue } + let isSelected = tab == selectedTab + guard page.isSelected != isSelected else { continue } + latestPages[tab] = page.withSelection(isSelected) + } + } + + private func updateScrollsToTop(for activeTab: VideoPageTab) { + introductionPage.setScrollsToTop(activeTab == .introduction) + commentsPage?.setScrollsToTop(activeTab == .comments) + } + + private func setHorizontalPagingActive(_ isActive: Bool) { + guard pagerPosition.setPagingActive(isActive) else { return } + updateHeaderAttachmentForCurrentState() + if !isActive { + syncInactivePageHeaderOffset() + } + } + + private func syncInactivePageHeaderOffset(activeOffset providedActiveOffset: CGFloat? = nil) { + guard !pagerPosition.isPagingActive else { + return + } + guard let activePage = verticalPageController(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) else { + return + } + let activeOffset = providedActiveOffset ?? activePage.normalizedContentOffsetY + guard let page = latestPages[VideoPageTab.page(at: pagerPosition.selectedIndex)] else { return } + let nextSyncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: activeOffset) + for tab in VideoPageTab.allCases where tab.pageIndex != pagerPosition.selectedIndex { + verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(nextSyncState.inactiveNormalizedOffsetY) + } + } + + private func verticalPageController(for tab: VideoPageTab) -> VideoDetailVerticalScrollPageViewController? { + switch tab { + case .introduction: + return introductionPage + case .comments: + return commentsPage + } + } + + private func prepareCommentsPageIfNeeded(for comments: VideoDetailTabPage) { + let nativeScrollView = comments.nativeListScrollView + if let commentsPage, commentsListScrollView === nativeScrollView { + return + } + if let commentsPage { + commentsPage.willMove(toParent: nil) + commentsPage.view.removeFromSuperview() + commentsPage.removeFromParent() + } + let nextCommentsPage = VideoDetailVerticalScrollPageViewController( + tab: .comments, + listScrollView: nativeScrollView + ) + nextCommentsPage.onHeaderOffsetChanged = { [weak self] tab, offset in + self?.updateHeaderContainerPosition(for: tab, offsetY: offset) + } + addPage(nextCommentsPage) + NSLayoutConstraint.activate([ + nextCommentsPage.view.leadingAnchor.constraint(equalTo: introductionPage.view.trailingAnchor), + nextCommentsPage.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + nextCommentsPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), + nextCommentsPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + nextCommentsPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + nextCommentsPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + ]) + commentsPage = nextCommentsPage + commentsListScrollView = nativeScrollView + } + + private func updateHeaderHosts( + headerContentRevision: Int, + continuationHeader: AnyView, + continuationProgress: CGFloat, + isContinuationHeaderInteractive: Bool, + pinHeader: AnyView, + page tab: VideoPageTab + ) { + guard let page = latestPages[tab] else { return } + headerContainerView.pinHeaderHeight = page.headerGeometry.pinHeaderHeight + headerContainerView.continuationHeaderHeight = continuationHeaderHeight(for: page) + headerContainerView.isContinuationHeaderInteractive = isContinuationHeaderInteractive + if self.headerContentRevision != headerContentRevision { + self.headerContentRevision = headerContentRevision + continuationHeaderHost.rootView = continuationHeader + pinHeaderHost.rootView = pinHeader + } + layoutHeaderHosts() + applyContinuationHeaderPresentation(progress: continuationProgress) + } + + private func layoutHeaderHosts() { + guard let page = latestPages[activeHeaderTab] else { return } + let width = view.bounds.width + let contentTopInset = page.headerGeometry.contentTopInset + let continuationHeight = continuationHeaderHeight(for: page) + headerContainerView.continuationHeaderHeight = continuationHeight + headerContainerView.pinHeaderHeight = page.headerGeometry.pinHeaderHeight + headerContainerView.frame = CGRect( + x: 0, + y: headerContainerView.frame.origin.y, + width: width, + height: contentTopInset + ) + continuationHeaderHost.view.frame = CGRect( + x: 0, + y: max(page.headerGeometry.headerHeight - continuationHeight, 0), + width: width, + height: continuationHeight + ) + pinHeaderHost.view.frame = CGRect( + x: 0, + y: page.headerGeometry.headerHeight, + width: width, + height: page.headerGeometry.pinHeaderHeight + ) + } + + private func continuationHeaderHeight(for page: VideoDetailTabPage) -> CGFloat { + max(page.headerGeometry.pinnedVisibleHeight - page.headerGeometry.pinHeaderHeight, 0) + } + + private func applyContinuationHeaderPresentation(progress: CGFloat) { + let clampedProgress = min(max(progress, 0), 1) + continuationHeaderHost.view.alpha = clampedProgress + continuationHeaderHost.view.transform = CGAffineTransform( + translationX: 0, + y: -8 * (1 - clampedProgress) + ) + } + + private func updateHeaderAttachmentForCurrentState() { + let headerTab = activeHeaderTab + guard let page = latestPages[headerTab] else { return } + layoutHeaderHosts() + guard let pageController = verticalPageController(for: headerTab) else { return } + let offset = pageController.normalizedContentOffsetY + let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offset) + attachHeader(originY: syncState.headerContainerY) + } + + private var activeHeaderTab: VideoPageTab { + VideoPageTab.page(at: pagerPosition.selectedIndex) + } + + private func attachHeader(originY: CGFloat) { + if headerContainerView.superview == nil { + view.addSubview(headerContainerView) + } + var frame = headerContainerView.frame + frame.origin = CGPoint(x: 0, y: originY) + frame.size.width = view.bounds.width + headerContainerView.frame = frame + view.bringSubviewToFront(headerContainerView) + } + + private func updateHeaderContainerPosition(for tab: VideoPageTab, offsetY: CGFloat) { + guard tab == activeHeaderTab else { return } + guard let page = latestPages[tab] else { return } + let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offsetY) + if !pagerPosition.isPagingActive { + syncInactivePageHeaderOffset(activeOffset: offsetY) + } + attachHeader(originY: syncState.headerContainerY) + } + } + + final class PagingScrollView: UIScrollView { + var shouldBeginPagingPan: ((UIPanGestureRecognizer, UIView) -> Bool)? + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, + panGestureRecognizer === self.panGestureRecognizer { + return shouldBeginPagingPan?(panGestureRecognizer, self) ?? true + } + return super.gestureRecognizerShouldBegin(gestureRecognizer) + } + } + + final class PagingHeaderContainerView: UIView { + var pinHeaderHeight: CGFloat = 0 + var continuationHeaderHeight: CGFloat = 0 + var isContinuationHeaderInteractive = false + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + containsInteractiveHeaderPoint(point) + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard containsInteractiveHeaderPoint(point) else { + return nil + } + return super.hitTest(point, with: event) + } + + private func containsInteractiveHeaderPoint(_ point: CGPoint) -> Bool { + let continuationHeaderFrame = CGRect( + x: 0, + y: max(bounds.height - pinHeaderHeight - continuationHeaderHeight, 0), + width: bounds.width, + height: continuationHeaderHeight + ) + let pinHeaderFrame = CGRect( + x: 0, + y: bounds.height - pinHeaderHeight, + width: bounds.width, + height: pinHeaderHeight + ) + return pinHeaderFrame.contains(point) + || (isContinuationHeaderInteractive && continuationHeaderFrame.contains(point)) + } + } +} + +private extension UIView { + func hasScrollableHorizontalDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { + guard let hitView = hitTest(location, with: nil) else { return false } + var current: UIView? = hitView + while let view = current, view !== excludedView { + if let listScrollView = view as? UIScrollView, + listScrollView.isScrollEnabled, + listScrollView.panGestureRecognizer.isEnabled, + listScrollView.contentSize.width > listScrollView.bounds.width + 1, + listScrollView.contentSize.width > listScrollView.contentSize.height { + return true + } + current = view.superview + } + return false + } +} diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 765a61ad..ac72acad 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -2,30 +2,47 @@ import SwiftUI import UIKit import Han1meShared +private final class NativeCommentListHolder: ObservableObject { + let controller = CommentListTableController() + let tableView: UITableView + + init() { + tableView = controller.makeTableView() + } + + func update(_ model: CommentListTableModel) { + controller.update(model) + } + + func updateContentBottomPadding(_ bottomPadding: CGFloat) { + controller.updateContentBottomPadding(bottomPadding) + } + + func attachScrollDelegate(_ delegate: UIScrollViewDelegate?) { + controller.scrollDelegate = delegate + } +} + struct VideoDetailView: View { let videoCode: String private let videoFeature: VideoFeature private let commentFeature: CommentFeature + private let tabletLeftMinimumWidth: CGFloat = 620 + private let tabletSidebarMinimumWidth: CGFloat = 360 + private let playerContinuationStripHeight: CGFloat = 56 + private let pagerPinHeaderHeight: CGFloat = 48 + private let commentComposerBottomSlack: CGFloat = 24 @StateObject private var viewModel: VideoDetailViewModel - @State private var selectedTab = VideoPageTab.introduction + @StateObject private var commentViewModel: CommentViewModel + @StateObject private var nativeCommentList = NativeCommentListHolder() + @State private var pagerState = VideoDetailPagerState() @State private var isPlayerFullscreen = false - @State private var isPlayerCollapsed = false - /// True iff the player is currently playing (not paused / buffering). - /// Driven from KSPlayerView via the @Binding below. Used to lock the - /// player at full 16:9 height while playing — only paused state lets the - /// scroll-driven shrink behaviour engage. - @State private var isPlayerPlaying = false - /// True iff the user is currently driving the bottom ScrollView with - /// a finger (or inertial scroll is still running). Used to gate - /// `onScrollGeometryChange` so that phantom contentOffset reports - /// caused by unrelated layout passes (e.g. tapping to show - /// controls inside the player) don't shrink/grow the player area. - @State private var isUserScrollingBottom = false - /// Vertical scroll offset of the inline content area below the player, - /// measured from the natural top (>= 0). When the user scrolls UP (so - /// the offset grows), and the player is paused, the player shrinks - /// proportionally — Bilibili-style "follow finger" collapse. - @State private var bottomScrollOffset: CGFloat = 0 + @State private var playerPlayRequestToken = 0 + @State private var commentComposeText = "" + @State private var isCommentInternalOverlayActive = false + @State private var commentComposerHeight: CGFloat = CommentComposerBar.compactHeight + @State private var fullscreenOrientationTask: Task? + @State private var isFullscreenOrientationLocked = false /// Natural size of the loaded video (reported by KSPlayer the first time /// the underlying player gets a non-zero presentation size). Used to /// decide whether fullscreen should lock the device to portrait or @@ -45,10 +62,25 @@ struct VideoDetailView: View { self.videoFeature = videoFeature self.commentFeature = commentFeature _viewModel = StateObject(wrappedValue: VideoDetailViewModel(videoFeature: videoFeature)) + _commentViewModel = StateObject( + wrappedValue: CommentViewModel(feature: commentFeature, videoCode: videoCode) + ) } var body: some View { - content + GeometryReader { proxy in + ZStack(alignment: .bottom) { + content + .ignoresSafeArea(.container, edges: ignoredContainerSafeAreaEdges) + + rootCommentComposer(layoutSize: proxy.size) + } + .animation(.easeInOut(duration: 0.2), value: shouldShowRootCommentComposer) + .onPreferenceChange(CommentComposerHeightPreferenceKey.self) { height in + guard height > 0, abs(commentComposerHeight - height) > 0.5 else { return } + commentComposerHeight = height + } + } .logScreen("VideoDetail v=\(videoCode)") // Navigation bar (and its system back button) is hidden the // whole time. The player draws its own floating back button @@ -71,22 +103,17 @@ struct VideoDetailView: View { // again, producing the slide-in/out animation. .hidesTabBarOnAppear() .statusBarHidden(isPlayerFullscreen) - .ignoresSafeArea(edges: isPlayerFullscreen ? .all : []) .task { viewModel.loadIfNeeded(videoCode: videoCode) } - .refreshable { - // Refresh without the full-screen .loading spinner flash: - // keep the current content visible while re-fetching. The - // player is rebuilt on success (acceptable for an explicit - // refresh); this just removes the abrupt blank-out. - await viewModel.refresh(videoCode: videoCode) - } .onDisappear { + fullscreenOrientationTask?.cancel() + fullscreenOrientationTask = nil // KSPlayer pauses itself in its own .onDisappear; the // detail VM no longer owns a player. - if isPlayerFullscreen { + if isPlayerFullscreen || isFullscreenOrientationLocked { AppOrientationController.shared.unlockAfterFullscreen() + isFullscreenOrientationLocked = false } } // Apple-Music-style centred HUD for action results @@ -139,13 +166,7 @@ struct VideoDetailView: View { // already animated to its new size by then; the subsequent // orientation rotation is its own UIKit-driven animation // and doesn't fight with SwiftUI. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.30) { - if newValue { - AppOrientationController.shared.lockForFullscreen(to: fullscreenOrientation) - } else { - AppOrientationController.shared.unlockAfterFullscreen() - } - } + scheduleFullscreenOrientationUpdate(isFullscreen: newValue) } } @@ -156,8 +177,12 @@ struct VideoDetailView: View { /// 2. The user has the "force portrait fullscreen for vertical /// videos" preference enabled (default ON). private var fullscreenOrientation: VideoFullscreenOrientation { + fullscreenOrientation(forNaturalSize: videoNaturalSize) + } + + private func fullscreenOrientation(forNaturalSize naturalSize: CGSize?) -> VideoFullscreenOrientation { let isPortraitVideo: Bool = { - guard let size = videoNaturalSize else { return false } + guard let size = naturalSize else { return false } return size.height > size.width }() if isPortraitVideo && forcePortraitForVerticalVideos { @@ -166,6 +191,44 @@ struct VideoDetailView: View { return .landscape } + private var ignoredContainerSafeAreaEdges: Edge.Set { + isPlayerFullscreen ? .all : .bottom + } + + private var shouldShowRootCommentComposer: Bool { + guard !isPlayerFullscreen, pagerState.selectedTab == .comments else { return false } + guard !isCommentInternalOverlayActive else { return false } + guard isCommentComposerReady else { return false } + if case .loaded = viewModel.state { + return true + } + return false + } + + private var isCommentComposerReady: Bool { + if case .loaded = commentViewModel.state { + return true + } + return false + } + + @ViewBuilder + private func rootCommentComposer(layoutSize: CGSize) -> some View { + if shouldShowRootCommentComposer { + CommentComposerBar( + text: $commentComposeText, + isSending: commentViewModel.runningActionIDs.contains("post-comment"), + isReady: isCommentComposerReady, + onSubmit: submitComment + ) + .frame(width: leftPanelWidth(for: layoutSize)) + .reportCommentComposerHeight() + .frame(maxWidth: .infinity, alignment: .leading) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .zIndex(1) + } + } + @ViewBuilder private var content: some View { switch viewModel.state { @@ -203,33 +266,54 @@ struct VideoDetailView: View { // Phone / iPad portrait collapses to a single full-width left panel // (no sidebar), giving the same visual as before for those modes. GeometryReader { proxy in - let isWide = horizontalSizeClass == .regular - && proxy.size.width >= 900 - && proxy.size.width > proxy.size.height - && !isPlayerFullscreen - let leftWidth: CGFloat = isWide - ? min(max(proxy.size.width * 0.64, 620), proxy.size.width - 360) - : proxy.size.width + let isWide = usesTabletRelatedSidebar(for: proxy.size) + let leftWidth = leftPanelWidth(for: proxy.size) + let inlineHeight = inlinePlayerHeight(panelWidth: leftWidth) + let pagerMetrics = pagerState.layoutMetrics( + containerHeight: proxy.size.height, + rawCollapseDistance: playerCollapseDistance(panelWidth: leftWidth) + ) HStack(alignment: .top, spacing: 0) { - VStack(spacing: 0) { + ZStack(alignment: .top) { + let currentPlayerHeight = playerHeight( + panelWidth: leftWidth, + parentHeight: proxy.size.height + ) + // Keep the tab pager mounted during fullscreen. Rebuilding + // the SwiftUI-hosted intro/comment pages while the player + // animates back to inline is a visible hitch on iPadOS 16. + // It keeps the inline layout metrics while hidden, so exit + // fullscreen does not have to re-expand the whole pager. + belowPlayerScroll( + snapshot: snapshot, + showsRelated: !isWide, + collapseDistance: pagerMetrics.collapseDistance, + headerHeight: inlineHeight, + pinHeaderHeight: pagerPinHeaderHeight, + pinnedVisibleHeight: playerContinuationStripHeight + pagerPinHeaderHeight, + playerScrollAway: pagerMetrics.playerScrollAway, + continuationProgress: pagerMetrics.continuationProgress, + introductionContentClearance: introductionContentClearance( + usesTabletRelatedSidebar: isWide + ), + composerContentClearance: commentComposerContentClearance( + safeAreaBottom: proxy.safeAreaInsets.bottom + ) + ) + .frame(height: proxy.size.height) + .opacity(isPlayerFullscreen ? 0 : 1) + .allowsHitTesting(!isPlayerFullscreen) + playerArea(snapshot: snapshot) .frame( width: leftWidth, - height: playerHeight( - panelWidth: leftWidth, - parentHeight: proxy.size.height - ) + height: currentPlayerHeight ) - - if !isPlayerFullscreen { - // showsRelated=false on iPad regular landscape because the - // dedicated right sidebar already shows related videos — - // duplicating them in the bottom scroll would be redundant. - belowPlayerScroll(snapshot: snapshot, showsRelated: !isWide) - } + .offset(y: isPlayerFullscreen ? 0 : -pagerMetrics.playerScrollAway) } - .frame(width: leftWidth) + .frame(width: leftWidth, height: proxy.size.height, alignment: .top) + .clipped() if isWide { Divider() @@ -247,208 +331,512 @@ struct VideoDetailView: View { } } + private func usesTabletRelatedSidebar(for size: CGSize) -> Bool { + let isLandscape = currentInterfaceOrientation()?.isLandscape ?? (size.width > size.height) + return horizontalSizeClass == .regular + && size.width >= tabletLeftMinimumWidth + tabletSidebarMinimumWidth + && isLandscape + && !isPlayerFullscreen + } + + private func leftPanelWidth(for size: CGSize) -> CGFloat { + guard usesTabletRelatedSidebar(for: size) else { return size.width } + return min( + max(size.width * 0.64, tabletLeftMinimumWidth), + size.width - tabletSidebarMinimumWidth + ) + } + /// Player 高度: /// - 全屏:撑满整个父容器 - /// - 折叠:50pt 标题 strip - /// - inline:左 panel 宽度的 16:9(不再依赖父容器 height) + /// - inline:固定为左 panel 宽度的 16:9,不随底部内容滚动缩小 private func playerHeight(panelWidth: CGFloat, parentHeight: CGFloat) -> CGFloat { if isPlayerFullscreen { return parentHeight } - if isPlayerCollapsed { return 50 } - let baseHeight = panelWidth * 9 / 16 - // While playing, lock to full 16:9 — never shrink with scroll. - if isPlayerPlaying { return baseHeight } - // Paused: follow the user's scroll. As bottomScrollOffset grows - // (content scrolled up), the player shrinks proportionally, never - // below playerCollapsedFollowMinHeight so its overlay controls - // remain at least partly visible. - let minHeight: CGFloat = max(baseHeight * 0.32, 80) - let shrink = max(0, min(baseHeight - minHeight, bottomScrollOffset)) - return baseHeight - shrink + return inlinePlayerHeight(panelWidth: panelWidth) + } + + private func inlinePlayerHeight(panelWidth: CGFloat) -> CGFloat { + panelWidth * 9 / 16 + } + + private func playerCollapseDistance(panelWidth: CGFloat) -> CGFloat { + max(panelWidth * 9 / 16 - playerContinuationStripHeight, 1) } private func playerArea(snapshot: VideoDetailScreenSnapshot) -> some View { - // Shrunken iff the follow-finger collapse has actually engaged - // (paused, not fullscreen / strip-collapsed, and the user has - // scrolled the bottom content up). - let shrunken = !isPlayerFullscreen - && !isPlayerCollapsed - && !isPlayerPlaying - && bottomScrollOffset > 1 return KSPlayerView( snapshot: snapshot, isFullscreen: $isPlayerFullscreen, - isCollapsed: $isPlayerCollapsed, onProgress: { viewModel.recordPlaybackPosition(seconds: $0) }, onPlaybackEnded: { viewModel.recordPlaybackPosition(seconds: 0) }, onPlayingChanged: { newValue in - if isPlayerPlaying != newValue { - isPlayerPlaying = newValue + guard pagerState.isPlayerPlaying != newValue else { return } + withTransaction(Transaction(animation: nil)) { + pagerState.setPlayerPlaying(newValue) } }, onBack: { dismiss() }, - isShrunken: shrunken, - onRequestExpand: { - // First tap on a shrunken player expands it back to 16:9 - // by zeroing the scroll-driven shrink amount — and animate - // it so the player smoothly grows. - withAnimation(.easeInOut(duration: 0.25)) { - bottomScrollOffset = 0 - } - }, + playRequestToken: playerPlayRequestToken, onNaturalSize: { size in + let orientation = fullscreenOrientation(forNaturalSize: size) videoNaturalSize = size + if isPlayerFullscreen { + AppOrientationController.shared.lockForFullscreen(to: orientation) + isFullscreenOrientationLocked = true + } } ) } - private func belowPlayerScroll(snapshot: VideoDetailScreenSnapshot, showsRelated: Bool) -> some View { - let scrollContent = ScrollView { - // iOS 16/17 fallback: 0-height GR sentinel as the first child of - // the ScrollView. minY in the named coordinate space tracks the - // scroll content's vertical movement against the ScrollView's - // viewport: scroll up 100pt → sentinel.minY becomes -100. We - // negate so the published value grows positive. - // On iOS 18+ this co-exists with .onScrollGeometryChange below; - // whichever fires first wins, both produce the same result. - GeometryReader { proxy in - Color.clear.preference( - key: BottomScrollOffsetPreferenceKey.self, - value: -proxy.frame(in: .named("bottomScroll")).minY - ) + private func continuePlayingStrip(snapshot: VideoDetailScreenSnapshot, progress: CGFloat) -> some View { + Button { + withAnimation(.easeInOut(duration: 0.25)) { + pagerState.expandPlayer() } - .frame(height: 0) - - LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { - Section { - // Wrap the per-tab content in a Group with .id(selectedTab) - // so SwiftUI treats the two branches as distinct view - // identities. Without this, switching introduction → - // comments inside a LazyVStack section sometimes - // recycles the row and leaves contentSize stale, which - // showed as a blank page until the user nudged the - // ScrollView (re-laying out and refreshing the size). - Group { - switch selectedTab { - case .introduction: - AndroidStyleIntroduction( - snapshot: snapshot, - videoFeature: videoFeature, - commentFeature: commentFeature, - isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), - onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, - onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, - onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, - onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, - onShowMessage: { viewModel.showActionMessage($0) }, - showsRelated: showsRelated - ) - case .comments: - CommentView(videoCode: videoCode, commentFeature: commentFeature) - } - } - .id(selectedTab) - // Horizontal swipe to switch between introduction / - // comments. Use plain .gesture (NOT simultaneous / - // highPriority) so any nested horizontal-scroll - // ScrollView (系列影片 / 相关影片 grids) gets to - // claim the gesture first; SwiftUI only falls - // through to this DragGesture for the area above - // the horizontal strips. We require horizontal - // dominance + 60pt minimum, AND a 24pt left/right - // start-edge deadzone so the iOS swipe-back gesture - // (and any future right-edge system gesture) wins. - .gesture( - DragGesture(minimumDistance: 30, coordinateSpace: .local) - .onEnded { value in - let dx = value.translation.width - let dy = value.translation.height - guard abs(dx) > abs(dy) * 1.5, abs(dx) > 60 else { return } - guard value.startLocation.x > 24 else { return } - withAnimation(.easeInOut(duration: 0.2)) { - if dx < 0, selectedTab == .introduction { - selectedTab = .comments - } else if dx > 0, selectedTab == .comments { - selectedTab = .introduction - } - } - } + playerPlayRequestToken &+= 1 + } label: { + HStack(spacing: 10) { + Image(systemName: "play.circle.fill") + .font(.title3) + Text("继续播放") + .font(.subheadline.weight(.semibold)) + Text(snapshot.title) + .font(.caption) + .lineLimit(1) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + .foregroundStyle(.primary) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.ultraThinMaterial) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(progress) + .offset(y: -8 * (1 - progress)) + } + + private func belowPlayerScroll( + snapshot: VideoDetailScreenSnapshot, + showsRelated: Bool, + collapseDistance: CGFloat, + headerHeight: CGFloat, + pinHeaderHeight: CGFloat, + pinnedVisibleHeight: CGFloat, + playerScrollAway: CGFloat, + continuationProgress: CGFloat, + introductionContentClearance: CGFloat, + composerContentClearance: CGFloat + ) -> some View { + let contentRevision = tabContentRevision(snapshot: snapshot, showsRelated: showsRelated) + let headerContentRevision = pagerHeaderContentRevision(snapshot: snapshot) + + return CommentOverlayHost( + viewModel: commentViewModel, + onOverlayActivityChanged: { isActive in + isCommentInternalOverlayActive = isActive + } + ) { commentTableModel in + VideoDetailPagerContainer( + state: $pagerState, + collapseDistance: collapseDistance, + headerHeight: headerHeight, + pinHeaderHeight: pinHeaderHeight, + pinnedVisibleHeight: pinnedVisibleHeight, + playerScrollAway: playerScrollAway, + continuationProgress: continuationProgress, + introductionContentBottomPadding: introductionContentClearance, + commentsContentBottomPadding: composerContentClearance, + introductionContentRevision: contentRevision.introduction, + commentsContentRevision: contentRevision.comments, + headerContentRevision: headerContentRevision, + continuationHeader: { + continuePlayingStrip(snapshot: snapshot, progress: 1) + .frame(height: playerContinuationStripHeight) + }, + isContinuationHeaderInteractive: !isPlayerFullscreen && continuationProgress > 0.05, + introduction: { + AndroidStyleIntroduction( + snapshot: snapshot, + videoFeature: videoFeature, + commentFeature: commentFeature, + isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), + onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, + onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, + onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, + onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, + onShowMessage: { viewModel.showActionMessage($0) }, + showsRelated: showsRelated ) - } header: { - Picker("Content", selection: $selectedTab) { - ForEach(VideoPageTab.allCases) { tab in - Text(tab.title).tag(tab) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(.background) + .padding(.top, 16) + }, + comments: { + EmptyView() + }, + nativeCommentsListScrollView: nativeCommentList.tableView, + nativeCommentsAttachScrollDelegate: { delegate in + nativeCommentList.attachScrollDelegate(delegate) + }, + nativeCommentsUpdate: { + nativeCommentList.updateContentBottomPadding(composerContentClearance) + nativeCommentList.update(commentTableModel) } - } - .padding(.bottom, 24) - } - .coordinateSpace(name: "bottomScroll") - .onPreferenceChange(BottomScrollOffsetPreferenceKey.self) { value in - bottomScrollOffset = max(0, value) - } - - // iOS 18+ explicit scroll-offset reporting via Apple's public API. - // More reliable than GeometryReader-on-Lazy* containers, which can - // sometimes skip preference updates during inertial scrolling. - if #available(iOS 18.0, *) { - return AnyView( - scrollContent - .onScrollPhaseChange { _, newPhase in - // .idle and .animating are "not currently being - // driven by the user". We only treat .tracking - // (finger down + moving) and .decelerating - // (inertial after release) and .interacting as - // legitimate scroll signals. This prevents tap- - // -to-show-controls inside the player from - // accidentally pulsing bottomScrollOffset and - // resizing the player area. - isUserScrollingBottom = (newPhase == .tracking - || newPhase == .decelerating - || newPhase == .interacting) - } - .onScrollGeometryChange(for: CGFloat.self) { geom in - geom.contentOffset.y - } action: { _, newOffset in - guard isUserScrollingBottom else { return } - bottomScrollOffset = max(0, newOffset) - } ) + } + } + + private func submitComment() { + guard isCommentComposerReady else { return } + if commentViewModel.postComment(text: commentComposeText) { + commentComposeText = "" + } + } + + private func scheduleFullscreenOrientationUpdate(isFullscreen: Bool) { + fullscreenOrientationTask?.cancel() + fullscreenOrientationTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled, isPlayerFullscreen == isFullscreen else { return } + if isFullscreen { + AppOrientationController.shared.lockForFullscreen(to: fullscreenOrientation) + isFullscreenOrientationLocked = true + } else if isFullscreenOrientationLocked { + AppOrientationController.shared.unlockAfterFullscreen() + isFullscreenOrientationLocked = false + } + } + } + + private func commentComposerContentClearance(safeAreaBottom: CGFloat) -> CGFloat { + let containerBottomInset = currentWindowBottomSafeAreaInset() + let isKeyboardSafeAreaActive = safeAreaBottom > containerBottomInset + 1 + let composerHeight = max(commentComposerHeight, CommentComposerBar.compactHeight) + return composerHeight + + (isKeyboardSafeAreaActive ? 0 : containerBottomInset) + + commentComposerBottomSlack + } + + private func introductionContentClearance(usesTabletRelatedSidebar: Bool) -> CGFloat { + currentWindowBottomSafeAreaInset() + (usesTabletRelatedSidebar ? 0 : 24) + } + + private func currentWindowBottomSafeAreaInset() -> CGFloat { + currentWindowScene()? + .windows + .first { $0.isKeyWindow }? + .safeAreaInsets + .bottom ?? 0 + } + + private func currentInterfaceOrientation() -> UIInterfaceOrientation? { + currentWindowScene()?.interfaceOrientation + } + + private func currentWindowScene() -> UIWindowScene? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + } + + private func tabContentRevision( + snapshot: VideoDetailScreenSnapshot, + showsRelated: Bool + ) -> VideoDetailTabContentRevision { + var introductionHasher = Hasher() + introductionHasher.combine(showsRelated) + introductionHasher.combine(viewModel.isActionRunning("artistSubscription")) + snapshot.hash(into: &introductionHasher) + + var commentsHasher = Hasher() + commentsHasher.combine(ObjectIdentifier(commentViewModel)) + commentsHasher.combine(commentViewModel.sortMode.id) + for actionID in commentViewModel.runningActionIDs.sorted() { + commentsHasher.combine(actionID) + } + commentsHasher.combine(commentViewModel.sortedComments.count) + for comment in commentViewModel.sortedComments { + commentsHasher.combine(comment.id) + commentsHasher.combine(comment.username) + commentsHasher.combine(comment.date) + commentsHasher.combine(comment.content) + commentsHasher.combine(comment.hasMoreReplies) + commentsHasher.combine(comment.replyCount) + commentsHasher.combine(comment.thumbUp) + commentsHasher.combine(comment.likeCommentStatus) + commentsHasher.combine(comment.unlikeCommentStatus) + } + switch commentViewModel.state { + case .idle: + commentsHasher.combine("idle") + case .loading: + commentsHasher.combine("loading") + case .failed(let message): + commentsHasher.combine("failed") + commentsHasher.combine(message) + case .loaded(let snapshot): + commentsHasher.combine("loaded") + commentsHasher.combine(snapshot.comments.count) + } + + return VideoDetailTabContentRevision( + introduction: introductionHasher.finalize(), + comments: commentsHasher.finalize() + ) + } + + private func pagerHeaderContentRevision(snapshot: VideoDetailScreenSnapshot) -> Int { + var hasher = Hasher() + snapshot.hash(into: &hasher) + return hasher.finalize() + } +} + +private struct CommentComposerBar: View { + static let compactHeight: CGFloat = 49 + + @Binding var text: String + let isSending: Bool + let isReady: Bool + let onSubmit: () -> Void + @FocusState private var isFieldFocused: Bool + + private var canSubmit: Bool { + isReady && text.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 && !isSending + } + + var body: some View { + composerControls + .padding(.horizontal, 16) + .frame(minHeight: Self.compactHeight) + .frame(maxWidth: .infinity) + .commentComposerBarChrome() + .onValueChange(of: isFieldFocused) { isFocused in + guard isFocused else { return } + CommentKeyboardTransparency.applySoon() + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in + CommentKeyboardTransparency.applySoon() + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in + CommentKeyboardTransparency.applySoon() + } + .onDisappear { + isFieldFocused = false + } + } + + @ViewBuilder + private var composerControls: some View { + if #available(iOS 26.0, *) { + GlassEffectContainer(spacing: 10) { + composerControlRow + } } else { - return AnyView(scrollContent) + composerControlRow + } + } + + private var composerControlRow: some View { + HStack(spacing: 10) { + TextField("输入评论", text: $text, axis: .vertical) + .textFieldStyle(.plain) + .submitLabel(.send) + .lineLimit(1...4) + .focused($isFieldFocused) + .onSubmit { + guard canSubmit else { return } + onSubmit() + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .commentComposerFieldChrome() + .layoutPriority(1) + + Button(action: onSubmit) { + Image(systemName: isSending ? "hourglass" : "paperplane.fill") + .font(.headline) + .frame(width: 42, height: 42) + } + .disabled(!canSubmit) + .foregroundStyle(canSubmit ? Color.accentColor : Color.secondary) + .accessibilityLabel("发送评论") + .commentComposerSendButtonChrome(isEnabled: canSubmit) } } } -/// Reports the inline-content ScrollView's vertical offset from its top so -/// the player area can shrink (B-station-style) when the user scrolls up -/// while paused. Reduce policy: keep the largest reported value of a single -/// pass — there's only one ScrollView publishing into this key. -private struct BottomScrollOffsetPreferenceKey: PreferenceKey { +private struct CommentComposerHeightPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() + value = max(value, nextValue()) } } -private enum VideoPageTab: String, CaseIterable, Identifiable { - case introduction - case comments +private extension View { + func reportCommentComposerHeight() -> some View { + background( + GeometryReader { proxy in + Color.clear.preference( + key: CommentComposerHeightPreferenceKey.self, + value: proxy.size.height + ) + } + ) + } + + @ViewBuilder + func commentComposerFieldChrome() -> some View { + background(Color(.secondarySystemBackground), in: Capsule()) + .overlay { + Capsule() + .strokeBorder(Color.secondary.opacity(0.16), lineWidth: 0.5) + } + .contentShape(Capsule()) + } - var id: String { rawValue } + func commentComposerSendButtonChrome(isEnabled _: Bool) -> some View { + buttonStyle(.plain) + .background(Color(.secondarySystemBackground), in: Circle()) + .overlay { + Circle() + .strokeBorder(Color.secondary.opacity(0.14), lineWidth: 0.5) + } + .contentShape(Circle()) + } - var title: String { - switch self { - case .introduction: - return String(localized: "简介") - case .comments: - return String(localized: "评论") + func commentComposerBarChrome() -> some View { + background(Color(.systemGroupedBackground).opacity(0.96)) + .overlay(alignment: .top) { + Divider() + .opacity(0.35) + } + } +} + +private enum CommentKeyboardTransparency { + @MainActor + static func applySoon() { + guard #available(iOS 26.0, *) else { return } + apply() + for delay in [40_000_000, 120_000_000, 260_000_000] { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(delay)) + apply() + } } } + + @MainActor + private static func apply() { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .filter { isKeyboardWindow($0) } + .forEach { window in + clearKeyboardChrome(in: window, depth: 0) + } + } + + private static func isKeyboardWindow(_ window: UIWindow) -> Bool { + let className = NSStringFromClass(type(of: window)) + return className.contains("UIRemoteKeyboardWindow") + || className.contains("UITextEffectsWindow") + } + + private static func clearKeyboardChrome(in view: UIView, depth: Int) { + let className = NSStringFromClass(type(of: view)) + if depth <= 2 + || className.contains("InputSet") + || className.contains("Keyboard") + || className.contains("TextEffects") + || className.contains("VisualEffect") + || className.contains("Backdrop") + || className.contains("Material") { + view.backgroundColor = .clear + view.isOpaque = false + } + if let visualEffectView = view as? UIVisualEffectView { + visualEffectView.effect = nil + visualEffectView.contentView.backgroundColor = .clear + } + view.subviews.forEach { clearKeyboardChrome(in: $0, depth: depth + 1) } + } +} + + +private extension VideoDetailScreenSnapshot { + func hash(into hasher: inout Hasher) { + hasher.combine(videoCode) + hasher.combine(title) + hasher.combine(chineseTitle) + hasher.combine(videoDescription) + hasher.combine(views) + hasher.combine(tagSummary) + hasher.combine(sourceCount) + hasher.combine(defaultSourceLabel) + hasher.combine(defaultSourceUrl) + hasher.combine(uploadDate) + hasher.combine(coverUrl) + hasher.combine(artist) + hasher.combine(favTimes) + hasher.combine(isFav) + hasher.combine(csrfToken) + hasher.combine(currentUserId) + hasher.combine(isWatchLater) + hasher.combine(originalComic) + hasher.combine(playbackPositionMillis) + hasher.combine(tags) + hasher.combine(playbackSources) + hasher.combine(playlistName) + playlistVideos.forEach { $0.hash(into: &hasher) } + hasher.combine(myListItems) + relatedVideos.forEach { $0.hash(into: &hasher) } + } +} + +private extension VideoRelatedRow { + func hash(into hasher: inout Hasher) { + hasher.combine(videoCode) + hasher.combine(title) + hasher.combine(coverUrl) + hasher.combine(duration) + hasher.combine(views) + hasher.combine(artist) + hasher.combine(uploadTime) + hasher.combine(isPlaying) + } +} + +struct TapOnlyControl: View { + let isDisabled: Bool + let action: () -> Void + let label: () -> Label + + init( + isDisabled: Bool = false, + action: @escaping () -> Void, + @ViewBuilder label: @escaping () -> Label + ) { + self.isDisabled = isDisabled + self.action = action + self.label = label + } + + var body: some View { + label() + .opacity(isDisabled ? 0.45 : 1) + .contentShape(Rectangle()) + .onTapGesture { + guard !isDisabled else { return } + action() + } + .accessibilityAddTraits(.isButton) + .accessibilityAction { + guard !isDisabled else { return } + action() + } + } } private struct AndroidStyleIntroduction: View { @@ -464,7 +852,7 @@ private struct AndroidStyleIntroduction: View { let showsRelated: Bool var body: some View { - LazyVStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 16) { if let artist = snapshot.artist { ArtistCard( artist: artist, @@ -515,7 +903,8 @@ private struct AndroidStyleIntroduction: View { videos: snapshot.playlistVideos, videoFeature: videoFeature, commentFeature: commentFeature, - showPlaying: true + showPlaying: true, + showsMetadataFooter: false ) } @@ -539,6 +928,7 @@ private struct ArtistCard: View { let commentFeature: CommentFeature @Environment(\.searchFeature) private var searchFeature @State private var isConfirmingUnsubscribe = false + @State private var isShowingArtistVideos = false var body: some View { HStack(spacing: 12) { @@ -550,7 +940,7 @@ private struct ArtistCard: View { Spacer() - Button { + TapOnlyControl(isDisabled: isRunning) { if artist.isSubscribed { isConfirmingUnsubscribe = true } else { @@ -564,8 +954,6 @@ private struct ArtistCard: View { .padding(.vertical, 7) .background(Color.accentColor.opacity(0.14), in: Capsule()) } - .disabled(isRunning) - .buttonStyle(.plain) } .padding(12) .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) @@ -577,6 +965,16 @@ private struct ArtistCard: View { } message: { Text("确定要取消订阅吗?") } + .navigationDestination(isPresented: $isShowingArtistVideos) { + if let searchFeature { + ArtistVideosView( + artistName: artist.name, + searchFeature: searchFeature, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + } + } } /// Wraps the avatar + name + genre block in a NavigationLink that pushes @@ -585,18 +983,12 @@ private struct ArtistCard: View { /// production but keeps the view robust during previews / testing). @ViewBuilder private var artistInfoTappable: some View { - if let searchFeature { - NavigationLink { - ArtistVideosView( - artistName: artist.name, - searchFeature: searchFeature, - videoFeature: videoFeature, - commentFeature: commentFeature - ) + if searchFeature != nil { + TapOnlyControl { + isShowingArtistVideos = true } label: { artistInfoLabel } - .buttonStyle(.plain) } else { artistInfoLabel } @@ -681,10 +1073,12 @@ private struct ExpandableDescription: View { .lineLimit(expanded ? nil : 4) .textSelection(.enabled) - Button(expanded ? String(localized: "收起") : String(localized: "展开")) { + TapOnlyControl { withAnimation(.easeInOut(duration: 0.18)) { expanded.toggle() } + } label: { + Text(expanded ? String(localized: "收起") : String(localized: "展开")) } .font(.caption.weight(.semibold)) } @@ -701,15 +1095,16 @@ private struct ActionButtonRow: View { let onDownload: (VideoPlaybackSourceRow) -> Void @Environment(\.openURL) private var openURL @State private var isShowingMyList = false + @State private var isShowingMoreActions = false @State private var isShowingShareSheet = false @State private var isShowingDownloadQuality = false private var videoURL: URL? { - URL(string: "https://hanime1.me/watch?v=\(snapshot.videoCode)") + siteURL(path: "/watch") } private var downloadURL: URL? { - URL(string: "https://hanime1.me/download?v=\(snapshot.videoCode)") + siteURL(path: "/download") } /// Real downloadable sources (a concrete resolution + a usable URL). @@ -720,81 +1115,84 @@ private struct ActionButtonRow: View { snapshot.playbackSources.filter { $0.label.uppercased() != "AUTO" && !$0.url.isEmpty } } - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - LabelButton( - title: snapshot.isFav ? "已收藏" : "收藏", - systemImage: snapshot.isFav ? "heart.fill" : "heart", - action: onToggleFavorite - ) + private func siteURL(path: String) -> URL? { + guard var components = URLComponents(string: AppDomain.currentBaseURL) else { + return nil + } + components.path = path + components.queryItems = [ + URLQueryItem(name: "v", value: snapshot.videoCode) + ] + return components.url + } - LabelButton( - title: snapshot.isWatchLater ? "已稍后" : "稍后观看", - systemImage: "text.badge.plus", - action: onToggleWatchLater - ) + var body: some View { + HStack(spacing: 6) { + LabelButton( + title: snapshot.isFav ? "已收藏" : "收藏", + systemImage: snapshot.isFav ? "heart.fill" : "heart", + action: onToggleFavorite + ) - LabelButton( - title: "加入列表", - systemImage: "list.bullet", - action: { - if snapshot.myListItems.isEmpty { - onShowMessage(String(localized: "video.action.playlist.empty")) - } else { - isShowingMyList = true - } - } - ) + LabelButton( + title: snapshot.isWatchLater ? "已稍后" : "稍后观看", + systemImage: "text.badge.plus", + action: onToggleWatchLater + ) - LabelButton( - title: "下载", - systemImage: "arrow.down.circle", - action: { - if downloadableSources.isEmpty { - // No selectable resolutions parsed — defer to the - // site's official download page in the browser. - if let downloadURL { openURL(downloadURL) } - } else { - isShowingDownloadQuality = true - } - } - ) + LabelButton( + title: "更多", + systemImage: "ellipsis.circle", + action: { + isShowingMoreActions = true + } + ) - LabelButton( - title: "分享", - systemImage: "square.and.arrow.up", - action: { - if videoURL != nil { - isShowingShareSheet = true - } + LabelButton( + title: "分享", + systemImage: "square.and.arrow.up", + action: { + if videoURL != nil { + isShowingShareSheet = true } - ) + } + ) + } + .confirmationDialog("更多操作", isPresented: $isShowingMoreActions, titleVisibility: .visible) { + Button("加入列表") { + if snapshot.myListItems.isEmpty { + onShowMessage(String(localized: "video.action.playlist.empty")) + } else { + isShowingMyList = true + } + } - if snapshot.originalComic?.isEmpty == false { - LabelButton( - title: "原作漫画", - systemImage: "book", - action: { - if let originalComic = snapshot.originalComic, - let url = URL(string: originalComic) { - openURL(url) - } - } - ) + Button("下载") { + if downloadableSources.isEmpty { + // No selectable resolutions parsed — defer to the + // site's official download page in the browser. + if let downloadURL { openURL(downloadURL) } + } else { + isShowingDownloadQuality = true } + } - LabelButton( - title: "网页", - systemImage: "safari", - action: { - if let videoURL { - openURL(videoURL) - } + if snapshot.originalComic?.isEmpty == false { + Button("原作漫画") { + if let originalComic = snapshot.originalComic, + let url = URL(string: originalComic) { + openURL(url) } - ) + } } - .padding(.horizontal, 2) + + Button("网页") { + if let videoURL { + openURL(videoURL) + } + } + + Button("取消", role: .cancel) {} } .confirmationDialog("播放列表", isPresented: $isShowingMyList) { ForEach(snapshot.myListItems) { item in @@ -842,10 +1240,10 @@ private struct LabelButton: View { var action: () -> Void = {} var body: some View { - Button(action: action) { + TapOnlyControl(action: action) { LabelButtonContent(title: title, systemImage: systemImage) } - .buttonStyle(.borderless) + .frame(maxWidth: .infinity) } } @@ -861,8 +1259,7 @@ private struct LabelButtonContent: View { .font(.caption) .lineLimit(1) } - .frame(minWidth: 76) - .padding(.horizontal, 10) + .frame(maxWidth: .infinity) .padding(.vertical, 8) } } @@ -872,6 +1269,7 @@ private struct TagFlow: View { let videoFeature: VideoFeature let commentFeature: CommentFeature @Environment(\.searchFeature) private var searchFeature + @State private var selectedTag: String? var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -883,32 +1281,63 @@ private struct TagFlow: View { // same column width. FlowLayout(spacing: 8, lineSpacing: 8) { ForEach(Array(tags.enumerated()), id: \.offset) { _, tag in - if let searchFeature { - NavigationLink { - ArtistVideosView( - title: "#\(tag)", - mode: .keyword(tag), - searchFeature: searchFeature, - videoFeature: videoFeature, - commentFeature: commentFeature - ) + if searchFeature != nil { + TapOnlyControl { + selectedTag = tag } label: { - Text(tag).font(.caption) + TagChipText(tag: tag) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + Capsule() + .strokeBorder(Color.accentColor.opacity(0.45), lineWidth: 1) + ) } - .buttonStyle(.bordered) } else { // Defensive: if the search feature isn't injected ( // which shouldn't happen in production) the tag still // renders as a disabled bordered chip rather than // disappearing entirely. - Button(tag) { /* no-op */ } - .font(.caption) - .buttonStyle(.bordered) - .disabled(true) + TagChipText(tag: tag) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .foregroundStyle(.secondary) + .background( + Capsule() + .strokeBorder(Color.secondary.opacity(0.25), lineWidth: 1) + ) } } } } + .navigationDestination( + isPresented: Binding( + get: { selectedTag != nil }, + set: { if !$0 { selectedTag = nil } } + ) + ) { + if let selectedTag, let searchFeature { + ArtistVideosView( + title: "#\(selectedTag)", + mode: .keyword(selectedTag), + searchFeature: searchFeature, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + } + } + } +} + +private struct TagChipText: View { + let tag: String + + var body: some View { + Text(tag) + .font(.caption) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 240, alignment: .leading) } } diff --git a/scripts/check_video_pager_structure.sh b/scripts/check_video_pager_structure.sh new file mode 100644 index 00000000..70490fc8 --- /dev/null +++ b/scripts/check_video_pager_structure.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +PAGER_FILE="${1:-iosApp/VideoDetailPager.swift}" + +if [ ! -f "$PAGER_FILE" ]; then + echo "Missing pager file: $PAGER_FILE" >&2 + exit 1 +fi + +forbidden_patterns=( + "VideoDetailHeaderAttachmentState" + "headerAttachmentView" + "canAttachHeaderToListHeader" + "applyHeaderAttachment" +) + +for pattern in "${forbidden_patterns[@]}"; do + if grep -n "$pattern" "$PAGER_FILE"; then + echo "Forbidden pager header attachment pattern found: $pattern" >&2 + exit 1 + fi +done + +if ! grep -q "view.addSubview(headerContainerView)" "$PAGER_FILE"; then + echo "Pager header must be attached to PagingViewController.view." >&2 + exit 1 +fi + +if grep -nE "[A-Za-z0-9_]+\\.addSubview\\(headerContainerView\\)" "$PAGER_FILE" \ + | grep -v "view.addSubview(headerContainerView)"; then + echo "Pager header must not be attached to a list/header child view." >&2 + exit 1 +fi + +if grep -n "headerContainerView.removeFromSuperview()" "$PAGER_FILE"; then + echo "Pager header should remain in the root view hierarchy." >&2 + exit 1 +fi +