diff --git a/ylgamehall/Source/Audio/VoicePlayer.swift b/ylgamehall/Source/Audio/VoicePlayer.swift index 0999a25..50aee70 100644 --- a/ylgamehall/Source/Audio/VoicePlayer.swift +++ b/ylgamehall/Source/Audio/VoicePlayer.swift @@ -7,22 +7,22 @@ // 契约:docs/H5-Native-Contract.md §3.1 [5]mediaTypeAudio + §3.2 [7][8] // gameui_play_voice / gameui_stop_voice // -// ## 与原项目的行为对齐 +// ## 与原项目的行为差异(用户反馈驱动的修正) // -// daoqi msext NewRootVC.m / gameController.m 把 voicePlayer / User_id 作 -// 为 VC 成员变量;本类把同款状态封装为单例。关键行为: +// daoqi msext NewRootVC.m / gameController.m 把 voicePlayer / User_id 作为 +// VC 成员变量,覆盖式播放时直接 `voicePlayer = [[AVAudioPlayer alloc]...]` +// 替换 ivar,没有触发任何停旧 player 的回调;自然结束时再用最新的 +// `self.User_id`(已被新一条消息覆盖)调 stopradio → 发给 H5 的 +// `gameui_stop_voice` payload 实际是**新一条消息的 user_id**,导致旧消息 +// 对应的 H5 语音动画永远停不掉。 // -// - `lastUserId`:mediaTypeAudio 入参 user 字段(每次 mediaTypeAudio 进来 -// 都更新,无论是否真的播放——对齐 daoqi gameController.m:438 -// `self.User_id=userid;` 在 canbofang=NO 时也会执行) -// - `gameui_stop_voice` payload 用 `lastUserId`,不是 didFinishPlaying 传 -// 入的 player 关联值(对齐 daoqi stopradio 用 self.User_id 的行为;多条 -// 消息连发时 gameui_stop_voice 发最新 user,**这是 daoqi 的行为**) -// - 覆盖式 play:前一条还在播时收到第二条,直接覆盖 player(让旧的被 -// ARC/ARC-like 释放);对齐 daoqi `voicePlayer = [[AVAudioPlayer alloc]...]` -// 赋值行为 -// - 主动 stop **不**触发 onFinish(daoqi 没有主动 stop 路径,只有 -// audioPlayerDidFinishPlaying 触发 stopradio) +// 本类把 user 与 player 一一绑定(`currentUser`): +// - 覆盖式 play:在替换 player 之前主动 stop 旧 player 并触发 +// `onFinish(currentUser)`,让 H5 收到正确的 `gameui_stop_voice(被打断 user)` +// - 自然结束 / 解码失败:用 `currentUser` 而非任何后写入的"最近 user" +// 触发 onFinish,保证停的是真正结束那条 +// - 主动 stop():不触发 onFinish(保留 daoqi 语义;本项目目前也没有外部 +// 主动 stop 调用路径) // import AVFoundation @@ -36,42 +36,57 @@ public final class VoicePlayer: NSObject, AVAudioPlayerDelegate { private var player: AVAudioPlayer? - /// 最近一次 mediaTypeAudio 入参中的 user 字段。 - /// 对齐 daoqi self.User_id 行为(成员变量,每次 mediaTypeAudio 进来都更新)。 - public private(set) var lastUserId: String = "" + /// 当前正在播放的语音对应的 user。与 `player` 一一绑定:`player != nil` 时一定有值, + /// `player == nil` 时为 nil。覆盖式 play / didFinishPlaying / decodeError 都用这个 + /// 值触发 onFinish,确保 `gameui_stop_voice` payload 与真正结束 / 被中断的语音对应。 + private var currentUser: String? - /// 播放完成回调(AVAudioPlayer didFinishPlaying 触发,成功/失败都触发)。 - /// mediaTypeAudio handler 注册此 closure 触发反向调用 gameui_stop_voice。 - /// payload 是 `lastUserId`(不是 player 关联的 user,对齐 daoqi 行为)。 + /// 播放完成 / 被中断 / 解码失败统一回调。 + /// mediaTypeAudio handler 注册此 closure → 反向调 H5 `gameui_stop_voice`。 + /// payload 始终是与刚结束 / 被打断 player 绑定的 user(不是入参中最新的 user)。 public var onFinish: ((String) -> Void)? private override init() { super.init() } - /// 记录 mediaTypeAudio 入参的 user,**无论是否实际播放都要调**。 - /// 对齐 daoqi gameController.m:438(voicePlaying=0 时仍执行 `self.User_id=userid`)。 - public func recordIncomingUser(_ user: String) { - lastUserId = user - } + /// 播放 wav。若当前已有正在播放的 player,先 stop 旧 player 并触发 + /// `onFinish(被打断的 user)`,让 H5 把旧动画停掉;再启动新 player。 + /// - Throws: AVAudioPlayer 创建 / 启动失败 + public func play(_ wav: URL, user: String) throws { + // 1. 若已有 player 正在播放,主动通知"被中断",确保 H5 把旧动画停掉。 + // delegate 先置 nil 防止旧 player 的 stop / dealloc 后续异步触发 + // didFinishPlaying 又再发一次 onFinish。 + if let old = player { + old.delegate = nil + old.stop() + player = nil + if let interrupted = currentUser { + currentUser = nil + Self.log.debug("interrupted user=\(interrupted, privacy: .public)") + onFinish?(interrupted) + } else { + currentUser = nil + } + } - /// 播放 wav。若已有正在播放的 player,直接覆盖(对齐 daoqi 行为)。 - /// 调用方应在调用前调 `recordIncomingUser(user)` 更新 lastUserId。 - /// - Throws: AVAudioPlayer 创建/启动失败 - public func play(_ wav: URL) throws { + // 2. 创建并启动新 player let p = try AVAudioPlayer(contentsOf: wav) p.delegate = self guard p.prepareToPlay(), p.play() else { throw PlayerError.startFailed } player = p - Self.log.debug("play \(wav.lastPathComponent, privacy: .public) user=\(self.lastUserId, privacy: .public)") + currentUser = user + Self.log.debug("play \(wav.lastPathComponent, privacy: .public) user=\(user, privacy: .public)") } /// 主动停止当前播放(不触发 onFinish)。 /// 外部清理使用(如 H5 BackGameData 退出子游戏时); /// 业务正常路径下不会主动调,由 didFinishPlaying 自然完成。 public func stop() { + player?.delegate = nil player?.stop() player = nil + currentUser = nil } public enum PlayerError: Error, Sendable { @@ -110,8 +125,10 @@ public final class VoicePlayer: NSObject, AVAudioPlayerDelegate { /// 的 player 与当前 self.player 同身份时才清理 + 触发 onFinish。 private func handleFinish(playerIdentity: ObjectIdentifier, success: Bool) { guard let current = player, ObjectIdentifier(current) == playerIdentity else { return } + let user = currentUser player = nil - Self.log.debug("didFinish user=\(self.lastUserId, privacy: .public) success=\(success, privacy: .public)") - onFinish?(lastUserId) + currentUser = nil + Self.log.debug("didFinish user=\(user ?? "", privacy: .public) success=\(success, privacy: .public)") + if let user { onFinish?(user) } } } diff --git a/ylgamehall/Source/Share/WechatShare.swift b/ylgamehall/Source/Share/WechatShare.swift index 819d193..f21a1d1 100644 --- a/ylgamehall/Source/Share/WechatShare.swift +++ b/ylgamehall/Source/Share/WechatShare.swift @@ -2,161 +2,196 @@ // WechatShare.swift // ylgamehall // -// 微信分享 SharePlatform 实现 + 大厅专用 shareLobby() 路径。 +// 微信分享 SharePlatform 实现:剪贴板 + 引导框 + `weixin://` URL Scheme, +// 与 QQShare / DouyinShare 同款交互模式,**不依赖微信 OpenSDK 的分享 API**。 // -// 两条路径对应原 msext 两个 ViewController 中同名 handler 的差异行为: +// 设计取舍: +// - 微信 OpenSDK 的 sendReq 走"专有剪贴板 plist + AppID 后台白名单",与 +// QQ / 抖音的"明文 URL Scheme"开放策略不同;为统一三方分享交互体验、 +// 减少 SDK 协议变更带来的不稳定,分享路径改走剪贴板 + 手动粘贴。 +// - 微信 OpenSDK 二进制依然保留(WeChatSDK.register / WeChatAuth.authorize +// 用于登录),仅去掉分享 SDK 调用与 WeChatManager 中的分享桥接。 +// - scene 参数(friend / timeline)保留以满足 SharePlatform 协议契约,但 +// 剪贴板路径下用户进微信后自行决定粘贴到对话框还是朋友圈,文案统一不分。 // -// • share() — 对齐 daoqi msext `WechatShareManager.shareWithContent:` -// (子游戏 gameController.m 活路径)2 type 分支: -// - type == "2" → 截图分享(thumb = app icon) -// - else → 链接分享(thumb = app icon) +// 两条对外入口对应两个 ViewController 同名 handler 的差异(仅剪贴板内容分支不同): // -// • shareLobby() — 对齐 daoqi msext `NewRootVC.m:471-534` 3 type 分支: -// - type == "1" → 链接分享(thumb = app icon;原 msext -// 用 sharelogo.png,按用户决定 fallback 到 app icon) -// - type == "2" → 截图分享(thumb = 截图 40% 缩放) -// - 其他 → 远端图分享(NSData 直发 + thumb = 远端图 40% 缩放) +// • share() — 子游戏路径(gameController.m),2 type 分支: +// - type == "2" → 截图:UIPasteboard.image +// - else → 链接:UIPasteboard.string // -// fire-and-forget:H5 端的 sharesuccess 已在 FriendsShareHandler 立即触发, -// 此处不再回传结果(与原 dispatch_after 1s completion(YES) 实质等价)。 +// • shareLobby() — 大厅路径(NewRootVC.m),3 type 分支: +// - type == "1" → 链接 +// - type == "2" → 截图 +// - 其他 → 远端图(下载 → UIPasteboard.image) +// +// fire-and-forget:H5 端 sharesuccess 已在 FriendsShareHandler 立即触发, +// 此处不再回传结果。 // import Foundation import UIKit +import os.log @MainActor public final class WechatShare: SharePlatform { public static let shared = WechatShare() + private static let log = Logger(subsystem: "ylgamehall", category: "WechatShare") + + private static let wechatScheme = "weixin://" + public let name = "WeChat" public var isInstalled: Bool { - WXApi.isWXAppInstalled() + guard let url = URL(string: Self.wechatScheme) else { return false } + return UIApplication.shared.canOpenURL(url) } public init() {} + // MARK: - 子游戏路径(gameController.m) + public func share(_ content: ShareContent, scene: ShareScene) { - guard WXApi.isWXAppInstalled() else { return } + guard isInstalled else { return } - let wxScene: WeChatManager.ShareScene = (scene == .timeline) ? .timeline : .session - let thumb = Self.appIconImage() - - Task { @MainActor in - do { - if content.type == "2" { - // 截图分享(msext WechatShareManager isScreenshotShare 分支) - let shot = try ImageProvider.captureScreenshot() - guard let imageData = shot.jpegData(compressionQuality: 0.6) else { return } - try await WeChatManager.shared.shareImage( - .init( - imageData: imageData, - thumb: thumb, - messageExt: content.desc, - mediaTagName: "\(Self.appTagPrefix)游戏截图分享" - ), - scene: wxScene - ) - } else { - // 链接分享(msext else 分支,含 type=="1" 与任何其它值) - try await WeChatManager.shared.shareLink( - .init( - url: content.webpageUrl, - title: content.title, - desc: content.desc, - thumb: thumb, - mediaTagName: "\(Self.appTagPrefix)游戏分享" - ), - scene: wxScene - ) - } - } catch { - // SDK 回包失败不向 H5 反馈(sharesuccess 已乐观发出,对齐原项目) - } + if content.type == "2" { + shareScreenshot() + } else { + shareLink(content) } } // MARK: - 大厅路径(NewRootVC.m) - /// 大厅页 H5 调 friendsShare 的微信分享路径,对齐 msext NewRootVC.m - /// :471-534 完整 3 分支结构。直发微信无 SharePanel。 public func shareLobby(_ content: ShareContent, scene: ShareScene) { - guard WXApi.isWXAppInstalled() else { return } + guard isInstalled else { return } - let wxScene: WeChatManager.ShareScene = (scene == .timeline) ? .timeline : .session - let appIcon = Self.appIconImage() + switch content.type { + case "1": + shareLink(content) + case "2": + shareScreenshot() + default: + shareRemoteImage(content) + } + } + // MARK: - 截图分享 + + /// 截图 → UIPasteboard.image → 弹引导框(不自动拉起微信,等用户点"立即打开")。 + private func shareScreenshot() { + do { + let shot = try ImageProvider.captureScreenshot() + UIPasteboard.general.image = shot + Self.log.debug("WechatShare 截图已复制到剪贴板") + } catch { + Self.log.error("WechatShare 截图失败 \(error.localizedDescription, privacy: .public)") + return + } + + let message = """ + ✅ 图片已复制到剪贴板 + + 🎯 分享到好友、群聊或朋友圈步骤: + 1️⃣ 打开微信,选择好友、群聊或朋友圈 + 2️⃣ 在输入框长按粘贴图片 + 3️⃣ 点击发送即可分享 + """ + showGuidanceAlert(title: "微信分享准备完成", message: message) + } + + // MARK: - 链接分享 + + /// title + desc + url 拼字符串 → UIPasteboard.string → 弹引导框。 + /// 三字段均空时用兜底文案"来自进贤聚友棋牌的精彩内容分享"(与 DouyinShare 同款策略)。 + private func shareLink(_ content: ShareContent) { + var parts: [String] = [] + if !content.title.isEmpty { parts.append(content.title) } + if !content.desc.isEmpty { parts.append(content.desc) } + if !content.webpageUrl.isEmpty { parts.append(content.webpageUrl) } + let shareText = parts.isEmpty + ? "来自进贤聚友棋牌的精彩内容分享" + : parts.joined(separator: "\n") + UIPasteboard.general.string = shareText + Self.log.debug("WechatShare 链接已复制到剪贴板") + + let message = """ + ✅ 内容已复制到剪贴板 + + 🎯 分享到好友、群聊或朋友圈步骤: + 1️⃣ 打开微信,选择好友、群聊或朋友圈 + 2️⃣ 在输入框长按粘贴内容 + 3️⃣ 点击发送即可分享 + """ + showGuidanceAlert(title: "微信分享准备完成", message: message) + } + + // MARK: - 远端图分享(大厅 shareLobby 的"其他"分支) + + /// 下载远端图 → UIPasteboard.image → 弹引导框。对齐 msext NewRootVC.m:526-534 + /// "公众号二维码分享"分支:原 SDK 路径直发 NSData,剪贴板路径写入解码后的 + /// UIImage 让用户在微信里长按粘贴。 + private func shareRemoteImage(_ content: ShareContent) { + guard let url = URL(string: content.webpageUrl) else { + Self.log.error("WechatShare 远端图 URL 解析失败") + return + } Task { @MainActor in do { - switch content.type { - case "1": - // 链接分享:原 msext 用 sharelogo.png;用户决定 fallback 到 app icon - try await WeChatManager.shared.shareLink( - .init( - url: content.webpageUrl, - title: content.title, - desc: content.desc, - thumb: appIcon, - mediaTagName: "\(Self.appTagPrefix)游戏下载链接" - ), - scene: wxScene - ) - case "2": - // 截图分享:thumb = 截图 40% 缩放(对齐 msext imageByScalingAndCroppingForSize:0.4x) - let shot = try ImageProvider.captureScreenshot() - guard let imageData = shot.jpegData(compressionQuality: 0.6) else { return } - try await WeChatManager.shared.shareImage( - .init( - imageData: imageData, - thumb: ImageProvider.scaledThumb(shot, scale: 0.4), - messageExt: content.desc, - mediaTagName: "\(Self.appTagPrefix)游戏截图分享" - ), - scene: wxScene - ) - default: - // 远端图分享(type == "" / "3" / 其他):下载 NSData 直发 + - // thumb = 远端图 40% 缩放。messageExt/mediaTagName 都是固定 - // 字面(msext NewRootVC.m:530 字面,非 H5 desc) - guard let url = URL(string: content.webpageUrl) else { return } - let (data, _) = try await URLSession.shared.data(from: url) - guard let image = UIImage(data: data) else { return } - try await WeChatManager.shared.shareImage( - .init( - imageData: data, - thumb: ImageProvider.scaledThumb(image, scale: 0.4), - messageExt: "\(Self.appTagPrefix)公众号二维码分享", - mediaTagName: "\(Self.appTagPrefix)公众号二维码分享" - ), - scene: wxScene - ) + let (data, _) = try await URLSession.shared.data(from: url) + guard let image = UIImage(data: data) else { + Self.log.error("WechatShare 远端图解码失败") + return } + UIPasteboard.general.image = image + Self.log.debug("WechatShare 远端图已复制到剪贴板") } catch { - // 与子游戏路径同样吞掉错误(sharesuccess 已乐观发出) + Self.log.error("WechatShare 远端图下载失败 \(error.localizedDescription, privacy: .public)") + return } + + let message = """ + ✅ 图片已复制到剪贴板 + + 🎯 分享到好友、群聊或朋友圈步骤: + 1️⃣ 打开微信,选择好友、群聊或朋友圈 + 2️⃣ 在输入框长按粘贴图片 + 3️⃣ 点击发送即可分享 + """ + showGuidanceAlert(title: "微信分享准备完成", message: message) } } - // MARK: - 共享常量 / 工具 + // MARK: - 引导弹窗 - /// msext WechatShareManager 字面常量 "进贤聚友棋牌"(与 daoqi - /// SGDefineInfo.h gamehallname 同源),用于 WXMediaMessage.mediaTagName 拼接。 - private static let appTagPrefix = "进贤聚友棋牌" + /// 弹"立即打开微信 / 稍后分享"二选一 Alert,与 QQShare / DouyinShare 同款 UX。 + private func showGuidanceAlert(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "立即打开微信", style: .default) { _ in + if let url = URL(string: Self.wechatScheme), + UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + }) + alert.addAction(UIAlertAction(title: "稍后分享", style: .cancel)) - /// app icon 缩略图。读 Info.plist `CFBundleIcons` → `CFBundlePrimaryIcon` - /// → `CFBundleIconFiles` 的最后一个(最高分辨率),与原 msext - /// WechatShareManager `getAppIconImage` 等价。失败兜底返回 1x1 透明图。 - private static func appIconImage() -> UIImage { - if let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any], - let primary = icons["CFBundlePrimaryIcon"] as? [String: Any], - let files = primary["CFBundleIconFiles"] as? [String], - let last = files.last, - let icon = UIImage(named: last) { - return icon + guard let topVC = Self.topViewController() else { return } + topVC.present(alert, animated: true) + } + + private static func topViewController() -> UIViewController? { + let scene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first(where: { $0.activationState == .foregroundActive }) + ?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + guard var top = scene?.windows.first(where: \.isKeyWindow)?.rootViewController + ?? scene?.windows.first?.rootViewController else { return nil } + while let presented = top.presentedViewController { top = presented } + if let nav = top as? UINavigationController, let visible = nav.visibleViewController { + return visible } - if let icon = UIImage(named: "AppIcon") { return icon } - let renderer = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)) - return renderer.image { _ in } + return top } }