修复四个问题: 录制后弹保存窗/本地文件播放/左上角锁定/音轨循环切换

1. HLSRecorder: 添加 pendingExportURL,录制完成后通过 SwiftUI sheet 弹出分享面板
2. PlayerBridge: AVAudioSession mode 改为 .default 兼容视频+音频,本地文件加载失败增强日志
3. PlayerContentView: iOS 锁屏功能,30秒无操作自动锁定,左上角图标切换锁状态
4. ControlToolbar: 音轨按钮改为直接循环切换(仅多音轨时可用),添加 cycleTrack() 方法
This commit is contained in:
yumoqing 2026-07-02 22:10:55 +08:00
parent a74a021518
commit 4c38b06860
3 changed files with 132 additions and 8 deletions

View File

@ -10,6 +10,7 @@ import Photos
final class HLSRecorder: ObservableObject { final class HLSRecorder: ObservableObject {
@Published var isRecording = false @Published var isRecording = false
@Published var durationText = "00:00" @Published var durationText = "00:00"
@Published var pendingExportURL: URL?
var onRecordingSaved: ((URL) -> Void)? var onRecordingSaved: ((URL) -> Void)?
var onError: ((String) -> Void)? var onError: ((String) -> Void)?
@ -303,9 +304,9 @@ final class HLSRecorder: ObservableObject {
saveToPhotoLibrary(url: url) saveToPhotoLibrary(url: url)
} }
// 2. // 2. SwiftUI sheet present UIViewController
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in DispatchQueue.main.async { [weak self] in
self?.presentActivitySheet(url: url) self?.pendingExportURL = url
} }
} }

View File

@ -360,6 +360,11 @@ struct PlayerContentView: View {
@State private var isHoveringIcon = false @State private var isHoveringIcon = false
@State private var hideTimer: Timer? @State private var hideTimer: Timer?
@State private var isHoveringToolbar = false @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() { private func resetHideTimer() {
hideTimer?.invalidate() 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 { var body: some View {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
Color.black.ignoresSafeArea() Color.black.ignoresSafeArea()
@ -383,9 +399,15 @@ struct PlayerContentView: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture(count: 2) { .onTapGesture(count: 2) {
#if os(iOS)
recordInteraction()
#endif
bridge.toggleFullscreen() bridge.toggleFullscreen()
} }
.onTapGesture { .onTapGesture {
#if os(iOS)
recordInteraction()
#endif
bridge.togglePlayPause() bridge.togglePlayPause()
} }
#if os(macOS) #if os(macOS)
@ -415,7 +437,7 @@ struct PlayerContentView: View {
// loading/buffering/error // loading/buffering/error
PlayerStatusOverlay(bridge: bridge) PlayerStatusOverlay(bridge: bridge)
// // iOS / macOS
MiniPlayerIcon() MiniPlayerIcon()
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
.padding(12) .padding(12)
@ -428,10 +450,19 @@ struct PlayerContentView: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
.highPriorityGesture( .highPriorityGesture(
TapGesture().onEnded { TapGesture().onEnded {
#if os(iOS)
lastInteraction = Date()
if isLocked {
isLocked = false
} else {
isLocked = true
}
#else
withAnimation(.easeInOut(duration: 0.25)) { withAnimation(.easeInOut(duration: 0.25)) {
showToolbar.toggle() showToolbar.toggle()
} }
resetHideTimer() resetHideTimer()
#endif
} }
) )
@ -440,7 +471,11 @@ struct PlayerContentView: View {
Spacer() Spacer()
if showToolbar { if showToolbar {
ControlToolbar(bridge: bridge, isHovering: $isHoveringToolbar) { ControlToolbar(bridge: bridge, isHovering: $isHoveringToolbar) {
#if os(iOS)
recordInteraction()
#else
resetHideTimer() resetHideTimer()
#endif
} }
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
@ -454,6 +489,19 @@ struct PlayerContentView: View {
.transition(.move(edge: .bottom).combined(with: .opacity)) .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 { .onAppear {
#if os(macOS) #if os(macOS)
@ -474,6 +522,17 @@ struct PlayerContentView: View {
WindowMouseMonitor.shared.startMonitoring() WindowMouseMonitor.shared.startMonitoring()
} }
#endif #endif
#if os(iOS)
// 530
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 .onChange(of: showToolbar) { _, newValue in
if newValue { if newValue {
@ -484,6 +543,10 @@ struct PlayerContentView: View {
} }
.onDisappear { .onDisappear {
hideTimer?.invalidate() hideTimer?.invalidate()
#if os(iOS)
lockCheckTimer?.invalidate()
lockCheckTimer = nil
#endif
} }
} }
} }
@ -520,6 +583,11 @@ struct BricksAppView: View {
bridge.showToastMsg(error.localizedDescription) bridge.showToastMsg(error.localizedDescription)
} }
} }
.sheet(item: $bridge.hlsRecorder.pendingExportURL) { url in
ActivityShareSheet(url: url) {
bridge.hlsRecorder.pendingExportURL = nil
}
}
#endif #endif
} }
} }
@ -625,7 +693,27 @@ struct ControlToolbar: View {
Divider().frame(height: 20).padding(.horizontal, 4) 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() } TB(icon: repeatIcon()) { bridge.cycleRepeatMode(); onTouch() }
@ -918,3 +1006,26 @@ struct PlayerStatusOverlay: View {
.background(Color.black.opacity(0.6)) .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

View File

@ -166,9 +166,9 @@ final class PlayerBridge: ObservableObject {
private func configureAudioSession() { private func configureAudioSession() {
do { do {
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .moviePlayback) try session.setCategory(.playback, mode: .default)
try session.setActive(true) try session.setActive(true)
NSLog("[MiniPlayer] AVAudioSession configured: playback + moviePlayback") NSLog("[MiniPlayer] AVAudioSession configured: playback + default")
} catch { } catch {
NSLog("[MiniPlayer] AVAudioSession setup failed: \(error.localizedDescription)") NSLog("[MiniPlayer] AVAudioSession setup failed: \(error.localizedDescription)")
} }
@ -311,7 +311,13 @@ final class PlayerBridge: ObservableObject {
} }
} }
case .failed: 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?.playerStatus = .error(errMsg)
self?.loadingTimer?.invalidate() self?.loadingTimer?.invalidate()
self?.loadingTimer = nil 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() { private func updateTrackLabel() {
currentTrackLabel = "🎵 \(L.track) \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))" currentTrackLabel = "🎵 \(L.track) \(currentTrackIndex + 1)/\(max(availableTracks.count, 1))"
} }