optimize the appicon of iOS
This commit is contained in:
@@ -93,6 +93,10 @@ final class PlayerController: ObservableObject {
|
||||
player.pause()
|
||||
}
|
||||
|
||||
func setVolume(_ volume: Float) {
|
||||
player.volume = volume
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
if player.timeControlStatus == .playing {
|
||||
pause()
|
||||
@@ -135,8 +139,10 @@ final class PlayerController: ObservableObject {
|
||||
return false
|
||||
}
|
||||
let position = snapshot.positionMs
|
||||
let target = item.sentences.reversed().first { $0.startMs < position - 250 }
|
||||
?? item.sentences.first
|
||||
guard let target = item.sentences.reversed().first(where: { $0.startMs < position - 250 })
|
||||
?? item.sentences.first else {
|
||||
return false
|
||||
}
|
||||
seek(to: target.startMs)
|
||||
return true
|
||||
}
|
||||
@@ -255,7 +261,7 @@ final class PlayerController: ObservableObject {
|
||||
func showTransientMessage(_ message: String) {
|
||||
transientMessage = message
|
||||
transientTask?.cancel()
|
||||
transientTask = Task { [weak self] in
|
||||
transientTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 675 KiB After Width: | Height: | Size: 801 KiB |
62
ios/OralTrainer/Services/DubbingCompositor.swift
Normal file
62
ios/OralTrainer/Services/DubbingCompositor.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
|
||||
enum DubbingCompositorError: LocalizedError {
|
||||
case noAudioTracks
|
||||
case cannotReadDuration
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noAudioTracks:
|
||||
return "录音没有音频轨道"
|
||||
case .cannotReadDuration:
|
||||
return "无法读取录音时长"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DubbingCompositor {
|
||||
static func merge(urls: [URL], outputURL: URL) async throws {
|
||||
try await mergeSync(urls: urls, outputURL: outputURL)
|
||||
}
|
||||
|
||||
private static func mergeSync(urls: [URL], outputURL: URL) async throws {
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
let composition = AVMutableComposition()
|
||||
var currentTime = CMTime.zero
|
||||
for url in urls {
|
||||
let source = AVURLAsset(url: url)
|
||||
let tracks = try await source.loadTracks(withMediaType: .audio)
|
||||
guard let track = tracks.first else {
|
||||
throw DubbingCompositorError.noAudioTracks
|
||||
}
|
||||
let duration = try await source.load(.duration)
|
||||
guard duration.seconds.isFinite, duration.seconds > 0 else {
|
||||
throw DubbingCompositorError.cannotReadDuration
|
||||
}
|
||||
let timeRange = CMTimeRange(start: .zero, duration: duration)
|
||||
let compositionTrack = composition.addMutableTrack(
|
||||
withMediaType: .audio,
|
||||
preferredTrackID: kCMPersistentTrackID_Invalid
|
||||
)
|
||||
guard let compositionTrack else {
|
||||
throw DubbingCompositorError.noAudioTracks
|
||||
}
|
||||
try compositionTrack.insertTimeRange(timeRange, of: track, at: currentTime)
|
||||
currentTime = currentTime + duration
|
||||
}
|
||||
|
||||
guard let exporter = AVAssetExportSession(
|
||||
asset: composition,
|
||||
presetName: AVAssetExportPresetAppleM4A
|
||||
) else {
|
||||
throw DubbingCompositorError.noAudioTracks
|
||||
}
|
||||
exporter.outputURL = outputURL
|
||||
exporter.outputFileType = .m4a
|
||||
await exporter.export()
|
||||
guard exporter.status == .completed else {
|
||||
throw exporter.error ?? DubbingCompositorError.noAudioTracks
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@ enum ImitationAssessorError: LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
struct DubShareUploadRequest {
|
||||
let videoHash: String
|
||||
let title: String
|
||||
let segments: [(index: Int, audioURL: URL)]
|
||||
let scores: [Int: ImitationAssessmentResult]
|
||||
}
|
||||
|
||||
enum ImitationAssessor {
|
||||
/// 分块计算文件的 SHA-256(与安卓端一致,适合大视频文件)。
|
||||
static func sha256Hex(of fileURL: URL) throws -> String {
|
||||
@@ -100,6 +107,96 @@ enum ImitationAssessor {
|
||||
return try JSONDecoder().decode(ImitationAssessmentResult.self, from: result.body)
|
||||
}
|
||||
|
||||
static func uploadDubShare(request: DubShareUploadRequest) async throws -> String {
|
||||
let hash = request.videoHash.lowercased()
|
||||
guard hash.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||||
throw ImitationAssessorError.invalidHash
|
||||
}
|
||||
guard !request.segments.isEmpty else {
|
||||
throw ImitationAssessorError.cannotReadRecording
|
||||
}
|
||||
|
||||
let boundary = "----OralTrainer-\(UUID().uuidString)"
|
||||
var body = Data()
|
||||
appendField(&body, boundary: boundary, name: "video_hash", value: hash)
|
||||
appendField(&body, boundary: boundary, name: "title", value: request.title.isEmpty ? "我的口语配音" : request.title)
|
||||
|
||||
let segmentItems = request.segments
|
||||
.sorted { $0.index < $1.index }
|
||||
.map { "{\"sentence_index\":\($0.index)}" }
|
||||
appendField(&body, boundary: boundary, name: "segments", value: "[\(segmentItems.joined(separator: ","))]")
|
||||
|
||||
let scoreItems = request.segments
|
||||
.sorted { $0.index < $1.index }
|
||||
.compactMap { segment -> String? in
|
||||
guard let score = request.scores[segment.index] else { return nil }
|
||||
var fields = ["\"sentence_index\":\(segment.index)"]
|
||||
for (key, value) in [
|
||||
("overall_score", score.overallScore as Float?),
|
||||
("content_score", score.contentScore),
|
||||
("fluency_score", score.fluencyScore),
|
||||
("duration_score", score.durationScore),
|
||||
("pause_score", score.pauseScore),
|
||||
("speech_rate_score", score.speechRateScore),
|
||||
] {
|
||||
if let value = value {
|
||||
fields.append("\"\(key)\":\(value)")
|
||||
}
|
||||
}
|
||||
if let text = score.recognizedText {
|
||||
let escaped = text
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
fields.append("\"recognized_text\":\"\(escaped)\"")
|
||||
}
|
||||
return "{\(fields.joined(separator: ","))}"
|
||||
}
|
||||
appendField(&body, boundary: boundary, name: "scores", value: "[\(scoreItems.joined(separator: ","))]")
|
||||
|
||||
for segment in request.segments.sorted(by: { $0.index < $1.index }) {
|
||||
let audioData: Data
|
||||
do {
|
||||
audioData = try Data(contentsOf: segment.audioURL)
|
||||
} catch {
|
||||
throw ImitationAssessorError.cannotReadRecording
|
||||
}
|
||||
appendFile(
|
||||
&body,
|
||||
boundary: boundary,
|
||||
name: "files",
|
||||
filename: "dub-\(segment.index).m4a",
|
||||
mimeType: "audio/mp4",
|
||||
data: audioData
|
||||
)
|
||||
}
|
||||
body.append(Data("--\(boundary)--\r\n".utf8))
|
||||
|
||||
let result = try await HTTPClient.execute(
|
||||
baseURL: AppConfig.serverBaseURL,
|
||||
path: "api/v1/dub-shares",
|
||||
method: "POST",
|
||||
timeout: 120,
|
||||
headers: [
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "multipart/form-data; boundary=\(boundary)",
|
||||
],
|
||||
body: body
|
||||
)
|
||||
guard (200...299).contains(result.statusCode) else {
|
||||
let responseBody = String(data: result.body, encoding: .utf8) ?? ""
|
||||
throw HTTPClientError.badStatus(result.statusCode, responseBody)
|
||||
}
|
||||
struct ShareResponse: Decodable {
|
||||
let shareId: String
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case shareId = "share_id"
|
||||
}
|
||||
}
|
||||
let response = try JSONDecoder().decode(ShareResponse.self, from: result.body)
|
||||
let base = AppConfig.serverBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
return "\(base)/dub-shares/\(response.shareId)"
|
||||
}
|
||||
|
||||
private static func appendField(_ body: inout Data, boundary: String, name: String, value: String) {
|
||||
body.append(Data("--\(boundary)\r\n".utf8))
|
||||
body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8))
|
||||
|
||||
@@ -5,6 +5,7 @@ struct PlayerSectionView: View {
|
||||
@ObservedObject var controller: PlayerController
|
||||
var showsContinuousToggle: Bool
|
||||
@Binding var continuousPlayback: Bool
|
||||
var showsSubtitle: Binding<Bool>? = .constant(true)
|
||||
var onFullscreen: () -> Void
|
||||
|
||||
@State private var progressDragging = false
|
||||
@@ -13,7 +14,9 @@ struct PlayerSectionView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
playerSurface
|
||||
sentencePanel
|
||||
if showsSubtitle?.wrappedValue == true {
|
||||
sentencePanel
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
@@ -131,13 +131,25 @@ struct RootView: View {
|
||||
|
||||
private func enterFullscreen() {
|
||||
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
updateSupportedInterfaceOrientations()
|
||||
showFullscreenPlayer = true
|
||||
}
|
||||
|
||||
private func exitFullscreen() {
|
||||
AppDelegate.orientationLock = UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
updateSupportedInterfaceOrientations()
|
||||
showFullscreenPlayer = false
|
||||
}
|
||||
|
||||
private func updateSupportedInterfaceOrientations() {
|
||||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
let controllers = scenes.flatMap { $0.windows }.compactMap(\.rootViewController)
|
||||
for controller in controllers {
|
||||
if #available(iOS 16.0, *) {
|
||||
controller.setNeedsUpdateOfSupportedInterfaceOrientations()
|
||||
} else {
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ struct TestView: View {
|
||||
@State private var testStatus = "请先在训练模块选择一个云端课程"
|
||||
@State private var scoreSummary = "尚未评测"
|
||||
@State private var scoreDetail = ""
|
||||
@State private var dubSegments: [Int: URL] = [:]
|
||||
@State private var latestScores: [Int: ImitationAssessmentResult] = [:]
|
||||
@State private var mergedDubURL: URL?
|
||||
@State private var dubPlayer: AVPlayer?
|
||||
@State private var dubbingPlayback = false
|
||||
@State private var dubbingStatus = "配音:尚未生成"
|
||||
@State private var isGeneratingDub = false
|
||||
@State private var isSharingDub = false
|
||||
@State private var shareURL: URL?
|
||||
@State private var showShareSheet = false
|
||||
@State private var dubPlaybackObserver: NSObjectProtocol?
|
||||
@State private var testSubtitleVisible = true
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
@@ -20,8 +32,15 @@ struct TestView: View {
|
||||
controller: controller,
|
||||
showsContinuousToggle: false,
|
||||
continuousPlayback: .constant(true),
|
||||
showsSubtitle: $testSubtitleVisible,
|
||||
onFullscreen: onFullscreen
|
||||
)
|
||||
if testSubtitleVisible {
|
||||
Text(controller.currentSentence?.text ?? "当前句子")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(Theme.textDark)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("朗读评测(测试)")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
@@ -30,6 +49,12 @@ struct TestView: View {
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.accentDeep)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack(spacing: 8) {
|
||||
Toggle("显示字幕", isOn: $testSubtitleVisible)
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(Theme.textDark)
|
||||
.tint(Theme.accent)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
sentenceButton("上一句") {
|
||||
controller.seekToPreviousSentence()
|
||||
@@ -53,6 +78,21 @@ struct TestView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isAssessing)
|
||||
HStack(spacing: 8) {
|
||||
dubButton("播放配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
|
||||
toggleDubPlayback()
|
||||
}
|
||||
dubButton("生成配音", enabled: !dubSegments.isEmpty && !dubbingPlayback && !isGeneratingDub) {
|
||||
generateDubShareAudio()
|
||||
}
|
||||
dubButton("分享配音", enabled: mergedDubURL != nil && !dubbingPlayback && !isSharingDub) {
|
||||
shareMergedDub()
|
||||
}
|
||||
}
|
||||
Text(dubbingStatus)
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(Theme.accentDeep)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(scoreSummary)
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundColor(Theme.textDark)
|
||||
@@ -78,6 +118,14 @@ struct TestView: View {
|
||||
.onReceive(controller.$currentSentence) { _ in
|
||||
refreshTestUI()
|
||||
}
|
||||
.onDisappear {
|
||||
stopDubPlayback(message: nil)
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let url = shareURL {
|
||||
ActivityShareSheet(items: [url])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sentenceButton(_ label: String, action: @escaping () -> Void) -> some View {
|
||||
@@ -93,6 +141,20 @@ struct TestView: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func dubButton(_ label: String, enabled: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 38)
|
||||
.background(enabled ? Theme.buttonBlue : Theme.border)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
}
|
||||
|
||||
// MARK: - 录音
|
||||
|
||||
private func toggleRecording() {
|
||||
@@ -176,6 +238,16 @@ struct TestView: View {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return
|
||||
}
|
||||
guard let sentence = controller.currentSentence else {
|
||||
testStatus = "没有可归属的句子,本次录音已丢弃"
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return
|
||||
}
|
||||
if let oldURL = dubSegments[sentence.index] {
|
||||
try? FileManager.default.removeItem(at: oldURL)
|
||||
}
|
||||
dubSegments[sentence.index] = url
|
||||
mergedDubURL = nil
|
||||
submitAssessment(recordingURL: url)
|
||||
}
|
||||
|
||||
@@ -269,6 +341,119 @@ struct TestView: View {
|
||||
testStatus = "请先在训练模块选择一个云端课程"
|
||||
return
|
||||
}
|
||||
testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)"
|
||||
let currentRecording = dubSegments[sentence.index]
|
||||
let segmentLabel = currentRecording == nil ? "" : " · 本句已录音,重录将覆盖"
|
||||
testStatus = "评测对象:第 \(sentence.index + 1) 句(共 \(item.sentences.count) 句)\(segmentLabel)"
|
||||
if mergedDubURL == nil {
|
||||
dubbingStatus = dubSegments.isEmpty ? "配音:请先录音" : "配音:已保存 \(dubSegments.count) 句,可生成配音"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 配音
|
||||
|
||||
private func toggleDubPlayback() {
|
||||
if dubbingPlayback {
|
||||
stopDubPlayback(message: "已停止配音播放")
|
||||
return
|
||||
}
|
||||
let sentence = controller.currentSentence
|
||||
let audioURL = mergedDubURL ?? sentence.flatMap { dubSegments[$0.index] }
|
||||
guard let audioURL else { return }
|
||||
if mergedDubURL == nil, let sentence = sentence {
|
||||
controller.seek(to: sentence.startMs)
|
||||
}
|
||||
controller.pause()
|
||||
controller.setVolume(0)
|
||||
let player = dubPlayer ?? AVPlayer()
|
||||
dubPlayer = player
|
||||
player.replaceCurrentItem(with: AVPlayerItem(url: audioURL))
|
||||
dubbingPlayback = true
|
||||
dubbingStatus = mergedDubURL == nil ? "正在播放当前句配音,视频原声静音" : "正在播放整段配音,视频原声静音"
|
||||
player.play()
|
||||
if dubPlaybackObserver == nil {
|
||||
dubPlaybackObserver = NotificationCenter.default.addObserver(
|
||||
forName: AVPlayerItem.didPlayToEndTimeNotification,
|
||||
object: player.currentItem,
|
||||
queue: .main
|
||||
) { _ in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
stopDubPlayback(message: "配音播放完成")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopDubPlayback(message: String?) {
|
||||
dubPlayer?.pause()
|
||||
dubPlayer?.seek(to: .zero)
|
||||
controller.pause()
|
||||
controller.setVolume(1)
|
||||
dubbingPlayback = false
|
||||
if let dubPlaybackObserver {
|
||||
NotificationCenter.default.removeObserver(dubPlaybackObserver)
|
||||
self.dubPlaybackObserver = nil
|
||||
}
|
||||
if let message {
|
||||
dubbingStatus = message
|
||||
}
|
||||
}
|
||||
|
||||
private func generateDubShareAudio() {
|
||||
guard !dubSegments.isEmpty else { return }
|
||||
let segments = dubSegments.sorted { $0.key < $1.key }
|
||||
let outputURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("dubbing-\(Int(Date().timeIntervalSince1970 * 1000)).m4a")
|
||||
isGeneratingDub = true
|
||||
dubbingStatus = "正在合成 \(segments.count) 句配音…"
|
||||
Task {
|
||||
do {
|
||||
try await DubbingCompositor.merge(urls: segments.map(\.value), outputURL: outputURL)
|
||||
if let oldURL = mergedDubURL {
|
||||
try? FileManager.default.removeItem(at: oldURL)
|
||||
}
|
||||
mergedDubURL = outputURL
|
||||
dubbingStatus = "已合成 \(segments.count) 句配音,可点击“分享配音”"
|
||||
} catch {
|
||||
dubbingStatus = "合成失败:\(error.localizedDescription)"
|
||||
}
|
||||
isGeneratingDub = false
|
||||
}
|
||||
}
|
||||
|
||||
private func shareMergedDub() {
|
||||
guard let item = controller.currentItem, mergedDubURL != nil else { return }
|
||||
guard item.id.range(of: AppConfig.sha256Pattern, options: .regularExpression) != nil else {
|
||||
dubbingStatus = "分享失败:网页分享仅支持云端课程视频"
|
||||
return
|
||||
}
|
||||
isSharingDub = true
|
||||
dubbingStatus = "正在生成分享链接…"
|
||||
let request = DubShareUploadRequest(
|
||||
videoHash: item.id,
|
||||
title: item.title,
|
||||
segments: dubSegments.map { ($0.key, $0.value) },
|
||||
scores: latestScores
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
let link = try await ImitationAssessor.uploadDubShare(request: request)
|
||||
shareURL = URL(string: link)
|
||||
showShareSheet = true
|
||||
dubbingStatus = "已生成分享链接:\(link)"
|
||||
} catch {
|
||||
dubbingStatus = "分享失败:\(error.localizedDescription)"
|
||||
}
|
||||
isSharingDub = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActivityShareSheet: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user