From 4c38b06860a4e1dca5a0739e516931e739bcc38c Mon Sep 17 00:00:00 2001 From: yumoqing Date: Thu, 2 Jul 2026 22:10:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=9B=E4=B8=AA=E9=97=AE?= =?UTF-8?q?=E9=A2=98:=20=E5=BD=95=E5=88=B6=E5=90=8E=E5=BC=B9=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E7=AA=97/=E6=9C=AC=E5=9C=B0=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=92=AD=E6=94=BE/=E5=B7=A6=E4=B8=8A=E8=A7=92=E9=94=81?= =?UTF-8?q?=E5=AE=9A/=E9=9F=B3=E8=BD=A8=E5=BE=AA=E7=8E=AF=E5=88=87?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. HLSRecorder: 添加 pendingExportURL,录制完成后通过 SwiftUI sheet 弹出分享面板 2. PlayerBridge: AVAudioSession mode 改为 .default 兼容视频+音频,本地文件加载失败增强日志 3. PlayerContentView: iOS 锁屏功能,30秒无操作自动锁定,左上角图标切换锁状态 4. ControlToolbar: 音轨按钮改为直接循环切换(仅多音轨时可用),添加 cycleTrack() 方法 --- Sources/HLSRecorder.swift | 7 ++- Sources/MiniPlayerApp.swift | 115 +++++++++++++++++++++++++++++++++++- Sources/PlayerBridge.swift | 18 +++++- 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/Sources/HLSRecorder.swift b/Sources/HLSRecorder.swift index 53741ab..34bab47 100644 --- a/Sources/HLSRecorder.swift +++ b/Sources/HLSRecorder.swift @@ -10,6 +10,7 @@ import Photos final class HLSRecorder: ObservableObject { @Published var isRecording = false @Published var durationText = "00:00" + @Published var pendingExportURL: URL? var onRecordingSaved: ((URL) -> Void)? var onError: ((String) -> Void)? @@ -303,9 +304,9 @@ final class HLSRecorder: ObservableObject { saveToPhotoLibrary(url: url) } - // 2. 延迟弹出系统分享面板(等相册保存启动后再弹) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.presentActivitySheet(url: url) + // 2. 通过 SwiftUI sheet 弹出分享面板(而非直接 present UIViewController) + DispatchQueue.main.async { [weak self] in + self?.pendingExportURL = url } } diff --git a/Sources/MiniPlayerApp.swift b/Sources/MiniPlayerApp.swift index ff7f7f6..3e26e0e 100644 --- a/Sources/MiniPlayerApp.swift +++ b/Sources/MiniPlayerApp.swift @@ -360,6 +360,11 @@ struct PlayerContentView: View { @State private var isHoveringIcon = false @State private var hideTimer: Timer? @State private var isHoveringToolbar = false + #if os(iOS) + @State private var isLocked = false + @State private var lastInteraction = Date() + @State private var lockCheckTimer: Timer? + #endif private func resetHideTimer() { hideTimer?.invalidate() @@ -371,6 +376,17 @@ struct PlayerContentView: View { } } + #if os(iOS) + private func recordInteraction() { + lastInteraction = Date() + if isLocked { + // 有交互时不解锁,等待用户点击图标解锁 + // 仅重置交互时间推迟自动锁定 + } + resetHideTimer() + } + #endif + var body: some View { ZStack(alignment: .topLeading) { Color.black.ignoresSafeArea() @@ -383,9 +399,15 @@ struct PlayerContentView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .onTapGesture(count: 2) { + #if os(iOS) + recordInteraction() + #endif bridge.toggleFullscreen() } .onTapGesture { + #if os(iOS) + recordInteraction() + #endif bridge.togglePlayPause() } #if os(macOS) @@ -415,7 +437,7 @@ struct PlayerContentView: View { // 播放状态指示(loading/buffering/error) PlayerStatusOverlay(bridge: bridge) - // 左上角应用图标 — 点击切换控制栏 + // 左上角应用图标 — iOS 锁屏控制 / macOS 切换控制栏 MiniPlayerIcon() .frame(width: 32, height: 32) .padding(12) @@ -428,10 +450,19 @@ struct PlayerContentView: View { .contentShape(Rectangle()) .highPriorityGesture( TapGesture().onEnded { + #if os(iOS) + lastInteraction = Date() + if isLocked { + isLocked = false + } else { + isLocked = true + } + #else withAnimation(.easeInOut(duration: 0.25)) { showToolbar.toggle() } resetHideTimer() + #endif } ) @@ -440,7 +471,11 @@ struct PlayerContentView: View { Spacer() if showToolbar { ControlToolbar(bridge: bridge, isHovering: $isHoveringToolbar) { + #if os(iOS) + recordInteraction() + #else resetHideTimer() + #endif } .background( RoundedRectangle(cornerRadius: 12) @@ -454,6 +489,19 @@ struct PlayerContentView: View { .transition(.move(edge: .bottom).combined(with: .opacity)) } } + + // iOS 锁屏遮罩层(拦截触摸,仅左上角图标可点击解锁) + #if os(iOS) + if isLocked { + Color.black.opacity(0.01) + .ignoresSafeArea() + .allowsHitTesting(true) + .contentShape(Rectangle()) + .onTapGesture { + // 吞掉所有触摸 + } + } + #endif } .onAppear { #if os(macOS) @@ -474,6 +522,17 @@ struct PlayerContentView: View { WindowMouseMonitor.shared.startMonitoring() } #endif + + #if os(iOS) + // 锁屏自动检测定时器(每5秒检查,超过30秒无交互则锁定) + lockCheckTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in + DispatchQueue.main.async { + if !isLocked && Date().timeIntervalSince(lastInteraction) > 30 { + isLocked = true + } + } + } + #endif } .onChange(of: showToolbar) { _, newValue in if newValue { @@ -484,6 +543,10 @@ struct PlayerContentView: View { } .onDisappear { hideTimer?.invalidate() + #if os(iOS) + lockCheckTimer?.invalidate() + lockCheckTimer = nil + #endif } } } @@ -520,6 +583,11 @@ struct BricksAppView: View { bridge.showToastMsg(error.localizedDescription) } } + .sheet(item: $bridge.hlsRecorder.pendingExportURL) { url in + ActivityShareSheet(url: url) { + bridge.hlsRecorder.pendingExportURL = nil + } + } #endif } } @@ -625,7 +693,27 @@ struct ControlToolbar: View { Divider().frame(height: 20).padding(.horizontal, 4) // 音轨 - TB(icon: "music.note") { bridge.showTrackDialog = true; onTouch() } + if bridge.availableTracks.count <= 1 { + Button(action: {}) { + Text("🎵") + .font(.system(size: 14)) + .foregroundColor(.gray.opacity(0.4)) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .disabled(true) + } else { + Button(action: { bridge.cycleTrack(); onTouch() }) { + Text(bridge.currentTrackLabel) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.white) + .frame(minWidth: 50, height: 28) + .padding(.horizontal, 4) + .background(Color.white.opacity(0.15)) + .cornerRadius(4) + } + .buttonStyle(.plain) + } // 循环 TB(icon: repeatIcon()) { bridge.cycleRepeatMode(); onTouch() } @@ -918,3 +1006,26 @@ struct PlayerStatusOverlay: View { .background(Color.black.opacity(0.6)) } } + +#if os(iOS) +// MARK: - URL Identifiable 扩展(用于 .sheet(item:)) +extension URL: @retroactive Identifiable { + public var id: String { absoluteString } +} + +// MARK: - 系统分享面板(UIViewControllerRepresentable 包装) +struct ActivityShareSheet: UIViewControllerRepresentable { + let url: URL + let onDismiss: () -> Void + + func makeUIViewController(context: Context) -> UIActivityViewController { + let vc = UIActivityViewController(activityItems: [url], applicationActivities: nil) + vc.completionWithItemsHandler = { _, _, _, _ in + onDismiss() + } + return vc + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} +#endif diff --git a/Sources/PlayerBridge.swift b/Sources/PlayerBridge.swift index e1d9225..57408d9 100644 --- a/Sources/PlayerBridge.swift +++ b/Sources/PlayerBridge.swift @@ -166,9 +166,9 @@ final class PlayerBridge: ObservableObject { private func configureAudioSession() { do { let session = AVAudioSession.sharedInstance() - try session.setCategory(.playback, mode: .moviePlayback) + try session.setCategory(.playback, mode: .default) try session.setActive(true) - NSLog("[MiniPlayer] AVAudioSession configured: playback + moviePlayback") + NSLog("[MiniPlayer] AVAudioSession configured: playback + default") } catch { NSLog("[MiniPlayer] AVAudioSession setup failed: \(error.localizedDescription)") } @@ -311,7 +311,13 @@ final class PlayerBridge: ObservableObject { } } case .failed: - let errMsg = pi.error?.localizedDescription ?? "Unknown error" + let err = pi.error + let errMsg = err?.localizedDescription ?? "Unknown error" + let errCode = (err as NSError?)?.code ?? 0 + let isLocalFile = item.url.isFileURL + NSLog("[MiniPlayer] ❌ AVPlayerItem failed: error=%@, code=%d, domain=%@, localFile=%d, url=%@", + errMsg, errCode, (err as NSError?)?.domain ?? "none", isLocalFile ? 1 : 0, + item.url.absoluteString) self?.playerStatus = .error(errMsg) self?.loadingTimer?.invalidate() self?.loadingTimer = nil @@ -455,6 +461,12 @@ final class PlayerBridge: ObservableObject { } } + func cycleTrack() { + guard availableTracks.count > 1 else { return } + let nextIndex = (currentTrackIndex + 1) % availableTracks.count + selectTrack(index: nextIndex) + } + private func updateTrackLabel() { currentTrackLabel = "🎵 \(L.track) \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))" }