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

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 {
@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
}
}

View File

@ -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)
// 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
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

View File

@ -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))"
}