修复四个问题: 录制后弹保存窗/本地文件播放/左上角锁定/音轨循环切换
1. HLSRecorder: 添加 pendingExportURL,录制完成后通过 SwiftUI sheet 弹出分享面板 2. PlayerBridge: AVAudioSession mode 改为 .default 兼容视频+音频,本地文件加载失败增强日志 3. PlayerContentView: iOS 锁屏功能,30秒无操作自动锁定,左上角图标切换锁状态 4. ControlToolbar: 音轨按钮改为直接循环切换(仅多音轨时可用),添加 cycleTrack() 方法
This commit is contained in:
parent
a74a021518
commit
4c38b06860
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
// 锁屏自动检测定时器(每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
|
.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
|
||||||
|
|||||||
@ -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))"
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user