diff --git a/iosApp/KSPlayerView.swift b/iosApp/KSPlayerView.swift index 965452f4..598e8e39 100644 --- a/iosApp/KSPlayerView.swift +++ b/iosApp/KSPlayerView.swift @@ -16,19 +16,16 @@ import AVFoundation /// 隔离**,会让外层整个 app 进入 dark mode。`KSVideoPlayerViewBuilder` 是 internal enum /// 也用不了。 /// -/// 通过 `@Binding isFullscreen` / `@Binding isCollapsed` 让外部容器(VideoDetailView) +/// 通过 `@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). 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 +36,6 @@ 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 /// 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 @@ -178,35 +164,27 @@ 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 = {}, 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.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 { @@ -353,16 +331,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) @@ -485,18 +453,6 @@ struct KSPlayerView: View { // would keep playing audio simultaneously. coordinator.playerLayer?.pause() } - .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 - } - onControlsVisibilityChanged(false) - hideControlsTask?.cancel() - } .onValueChange(of: statusObserver.isWaitingForPlayback) { waiting in // Speed sampler only runs while the player is genuinely waiting // for data. Covers both initial asset-loading (currentItem @@ -721,12 +677,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 @@ -737,8 +687,7 @@ struct KSPlayerView: View { coordinator.isScaleAspectFill.toggle() } // 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. + // playback-rate menu) — see `bottomBar`. } } @@ -997,28 +946,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? { 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/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 765a61ad..1d18859d 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -9,23 +9,7 @@ struct VideoDetailView: View { @StateObject private var viewModel: VideoDetailViewModel @State private var selectedTab = VideoPageTab.introduction @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 /// 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 @@ -193,9 +177,10 @@ struct VideoDetailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) case .loaded(let snapshot): // Bilibili-style iPad layout: an outer HStack with two slots. - // - Slot 0: a VStack (the "left panel") that hosts player + scroll. - // Player is ALWAYS the first child of this VStack at a stable tree - // position, so size-class flips never reparent it (which would + // - Slot 0: a ZStack (the "left panel") that keeps the player + // below a higher-level paging container. + // Player is ALWAYS kept at a stable tree position, so size-class flips + // never reparent it (which would // rebuild @StateObject Coordinator + KSPlayerLayer → reload video). // - Slot 1: the related-videos sidebar, only mounted on iPad regular // landscape. Mounting/unmounting it does NOT touch slot 0. @@ -212,23 +197,12 @@ struct VideoDetailView: View { : proxy.size.width HStack(alignment: .top, spacing: 0) { - VStack(spacing: 0) { - playerArea(snapshot: snapshot) - .frame( - width: leftWidth, - height: playerHeight( - panelWidth: leftWidth, - parentHeight: proxy.size.height - ) - ) - - 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) - } - } + leftPanel( + snapshot: snapshot, + panelWidth: leftWidth, + panelHeight: proxy.size.height, + showsRelated: !isWide + ) .frame(width: leftWidth) if isWide { @@ -247,200 +221,229 @@ struct VideoDetailView: View { } } - /// Player 高度: - /// - 全屏:撑满整个父容器 - /// - 折叠:50pt 标题 strip - /// - inline:左 panel 宽度的 16:9(不再依赖父容器 height) + private func leftPanel( + snapshot: VideoDetailScreenSnapshot, + panelWidth: CGFloat, + panelHeight: CGFloat, + showsRelated: Bool + ) -> some View { + let currentPlayerHeight = playerHeight(panelWidth: panelWidth, parentHeight: panelHeight) + let allowsVideoOverlay = !isPlayerPlaying + let pagingTopOffset = allowsVideoOverlay ? 0 : currentPlayerHeight + let pagingHeight = max(0, panelHeight - pagingTopOffset) + let pagingTopInset = allowsVideoOverlay ? currentPlayerHeight : 0 + let pagingMinimumHitY = allowsVideoOverlay ? currentPlayerHeight : 0 + + return ZStack(alignment: .top) { + playerArea(snapshot: snapshot) + .frame(width: panelWidth, height: currentPlayerHeight) + .zIndex(0) + + if !isPlayerFullscreen { + TopPassthroughContainer(minimumHitY: pagingMinimumHitY) { + pagingLayer( + snapshot: snapshot, + topInset: pagingTopInset, + showsRelated: showsRelated + ) + } + .frame(width: panelWidth, height: pagingHeight) + .offset(y: pagingTopOffset) + .zIndex(1) + .animation(.easeInOut(duration: 0.25), value: isPlayerPlaying) + } + } + .frame(width: panelWidth, height: panelHeight, alignment: .top) + } + + /// Player 高度:全屏撑满父容器,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 panelWidth * 9 / 16 } 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( + KSPlayerView( snapshot: snapshot, isFullscreen: $isPlayerFullscreen, - isCollapsed: $isPlayerCollapsed, onProgress: { viewModel.recordPlaybackPosition(seconds: $0) }, onPlaybackEnded: { viewModel.recordPlaybackPosition(seconds: 0) }, onPlayingChanged: { newValue in - if isPlayerPlaying != newValue { + guard isPlayerPlaying != newValue else { return } + withAnimation(.easeInOut(duration: 0.25)) { isPlayerPlaying = 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 - } - }, onNaturalSize: { size in videoNaturalSize = size } ) } - 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 pagingLayer( + snapshot: VideoDetailScreenSnapshot, + topInset: CGFloat, + showsRelated: Bool + ) -> some View { + TabView(selection: $selectedTab) { + pagingScroll(topInset: topInset) { + 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 ) } - .frame(height: 0) + .tag(VideoPageTab.introduction) + + pagingScroll(topInset: topInset) { + CommentView(videoCode: videoCode, commentFeature: commentFeature) + } + .tag(VideoPageTab.comments) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + } + + private func pagingScroll( + topInset: CGFloat, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + Color.clear + .frame(height: topInset) - 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 - } - } - } - ) + content() + .padding(.top, 16) + .padding(.bottom, 24) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemGroupedBackground)) } 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) + pagingTabBar } } - .padding(.bottom, 24) } - .coordinateSpace(name: "bottomScroll") - .onPreferenceChange(BottomScrollOffsetPreferenceKey.self) { value in - bottomScrollOffset = max(0, value) + } + + private var pagingTabBar: some View { + Picker("Content", selection: $selectedTab) { + ForEach(VideoPageTab.allCases) { tab in + Text(tab.title).tag(tab) + } } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.background) + .frame(height: VideoPagingMetrics.tabBarHeight) + } +} - // 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) - } - ) - } else { - return AnyView(scrollContent) +private enum VideoPagingMetrics { + static let tabBarHeight: CGFloat = 48 +} + +private struct TopPassthroughContainer: UIViewControllerRepresentable { + let minimumHitY: CGFloat + let content: Content + + init(minimumHitY: CGFloat, @ViewBuilder content: () -> Content) { + self.minimumHitY = minimumHitY + self.content = content() + } + + func makeUIViewController(context: Context) -> PassthroughContainerController { + let controller = PassthroughContainerController(rootView: content) + controller.minimumHitY = minimumHitY + return controller + } + + func updateUIViewController(_ controller: PassthroughContainerController, context: Context) { + controller.rootView = content + controller.minimumHitY = minimumHitY + } +} + +private final class PassthroughContainerController: UIViewController { + private let hostingController: UIHostingController + + var minimumHitY: CGFloat = 0 { + didSet { + passthroughView.minimumHitYProvider = { [weak self] in self?.minimumHitY ?? 0 } } } + + var rootView: Content { + get { hostingController.rootView } + set { hostingController.rootView = newValue } + } + + private var passthroughView: TopPassthroughHostingView { + view as! TopPassthroughHostingView + } + + init(rootView: Content) { + hostingController = UIHostingController(rootView: rootView) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = TopPassthroughHostingView() + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + view.isOpaque = false + passthroughView.minimumHitYProvider = { [weak self] in self?.minimumHitY ?? 0 } + + addChild(hostingController) + hostingController.view.backgroundColor = .clear + hostingController.view.isOpaque = false + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingController.view) + NSLayoutConstraint.activate([ + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + hostingController.didMove(toParent: self) + } } -/// 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 { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() +private final class TopPassthroughHostingView: UIView { + var minimumHitYProvider: () -> CGFloat = { 0 } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard point.y >= minimumHitYProvider() else { return nil } + return super.hitTest(point, with: event) } } -private enum VideoPageTab: String, CaseIterable, Identifiable { +private enum VideoPageTab: String, CaseIterable, Identifiable, Hashable { case introduction case comments var id: String { rawValue } + var scrollCoordinateSpaceName: String { + "videoPageScroll-\(rawValue)" + } + var title: String { switch self { case .introduction: @@ -701,15 +704,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 +724,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) } - ) + } + } + + Button("网页") { + if let videoURL { + openURL(videoURL) + } } - .padding(.horizontal, 2) + + Button("取消", role: .cancel) {} } .confirmationDialog("播放列表", isPresented: $isShowingMyList) { ForEach(snapshot.myListItems) { item in @@ -846,6 +853,7 @@ private struct LabelButton: View { LabelButtonContent(title: title, systemImage: systemImage) } .buttonStyle(.borderless) + .frame(maxWidth: .infinity) } } @@ -861,8 +869,7 @@ private struct LabelButtonContent: View { .font(.caption) .lineLimit(1) } - .frame(minWidth: 76) - .padding(.horizontal, 10) + .frame(maxWidth: .infinity) .padding(.vertical, 8) } }