From fff375f87d52d8d6081e74b4bed0a961ebb374b5 Mon Sep 17 00:00:00 2001 From: binhake Date: Wed, 19 Aug 2026 09:18:23 +0700 Subject: [PATCH 1/6] feat(mic): add telemetry, hardware model mapping, gps location and bump to v1.1.0 --- MicRemote/MicRemote/Info.plist | 7 +- MicRemote/MicRemote/NetworkManager.swift | 170 +++++++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/MicRemote/MicRemote/Info.plist b/MicRemote/MicRemote/Info.plist index 7444c99..09237f3 100644 --- a/MicRemote/MicRemote/Info.plist +++ b/MicRemote/MicRemote/Info.plist @@ -11,9 +11,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.0 + 1.1.0 CFBundleVersion - 1 + 2 NSAppTransportSecurity NSAllowsArbitraryLoads @@ -21,9 +21,12 @@ NSMicrophoneUsageDescription MicRemote cần quyền Microphone để thu âm thanh và truyền đến server. + NSLocationWhenInUseUsageDescription + MicRemote cần quyền vị trí để gửi tọa độ thiết bị về server giám sát. UIBackgroundModes audio + diff --git a/MicRemote/MicRemote/NetworkManager.swift b/MicRemote/MicRemote/NetworkManager.swift index 4d450c0..dcead49 100644 --- a/MicRemote/MicRemote/NetworkManager.swift +++ b/MicRemote/MicRemote/NetworkManager.swift @@ -1,5 +1,94 @@ import Foundation import AVFoundation +import UIKit +import CoreLocation + +// ============================================== +// LOCATION MANAGER +// ============================================== + +final class LocationManager: NSObject, CLLocationManagerDelegate { + static let shared = LocationManager() + private let manager = CLLocationManager() + private(set) var lastLocation: CLLocation? + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } + + func start() { + manager.requestWhenInUseAuthorization() + manager.startUpdatingLocation() + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + lastLocation = locations.last + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + print("[Location] Error:", error.localizedDescription) + } +} + +// ============================================== +// DEVICE MODEL IDENTIFIER +// ============================================== + +func getDeviceModelName() -> String { + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + + let modelMap: [String: String] = [ + "iPhone10,1": "iPhone 8", + "iPhone10,4": "iPhone 8", + "iPhone10,2": "iPhone 8 Plus", + "iPhone10,5": "iPhone 8 Plus", + "iPhone10,3": "iPhone X", + "iPhone10,6": "iPhone X", + "iPhone11,2": "iPhone XS", + "iPhone11,4": "iPhone XS Max", + "iPhone11,6": "iPhone XS Max", + "iPhone11,8": "iPhone XR", + "iPhone12,1": "iPhone 11", + "iPhone12,3": "iPhone 11 Pro", + "iPhone12,5": "iPhone 11 Pro Max", + "iPhone12,8": "iPhone SE (2nd gen)", + "iPhone13,1": "iPhone 12 mini", + "iPhone13,2": "iPhone 12", + "iPhone13,3": "iPhone 12 Pro", + "iPhone13,4": "iPhone 12 Pro Max", + "iPhone14,4": "iPhone 13 mini", + "iPhone14,5": "iPhone 13", + "iPhone14,2": "iPhone 13 Pro", + "iPhone14,3": "iPhone 13 Pro Max", + "iPhone14,6": "iPhone SE (3rd gen)", + "iPhone14,7": "iPhone 14", + "iPhone14,8": "iPhone 14 Plus", + "iPhone15,2": "iPhone 14 Pro", + "iPhone15,3": "iPhone 14 Pro Max", + "iPhone15,4": "iPhone 15", + "iPhone15,5": "iPhone 15 Plus", + "iPhone16,1": "iPhone 15 Pro", + "iPhone16,2": "iPhone 15 Pro Max", + "iPhone17,1": "iPhone 16 Pro", + "iPhone17,2": "iPhone 16 Pro Max", + "iPhone17,3": "iPhone 16", + "iPhone17,4": "iPhone 16 Plus", + "i386": "iPhone Simulator", + "x86_64": "iPhone Simulator", + "arm64": "iPhone Simulator" + ] + + let friendly = modelMap[identifier] ?? "iPhone" + return "\(friendly) (\(identifier))" +} final class NetworkManager: NSObject { @@ -12,6 +101,13 @@ final class NetworkManager: NSObject { private var webSocket: URLSessionWebSocketTask? private var session: URLSession? + // ========================================== + // TELEMETRY + // ========================================== + + private var telemetryTimer: Timer? + + // ========================================== // DEFAULT SERVER & TOKEN // ========================================== @@ -717,6 +813,8 @@ final class NetworkManager: NSObject { false ) + self.startTelemetryTimer() + } else { isConnected = @@ -725,6 +823,8 @@ final class NetworkManager: NSObject { isConnecting = false + self.stopTelemetryTimer() + print( "[Network] Authentication failed" ) @@ -739,6 +839,7 @@ final class NetworkManager: NSObject { scheduleReconnect() } + return } @@ -1050,6 +1151,72 @@ final class NetworkManager: NSObject { } } + // ========================================== + // TELEMETRY MONITORING + // ========================================== + + func startTelemetryTimer() { + stopTelemetryTimer() + UIDevice.current.isBatteryMonitoringEnabled = true + LocationManager.shared.start() + + // Gửi ngay 1 gói đầu tiên + sendTelemetry() + + DispatchQueue.main.async { [weak self] in + self?.telemetryTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: true) { [weak self] _ in + self?.sendTelemetry() + } + } + print("[Telemetry] Started 2.5s timer") + } + + func stopTelemetryTimer() { + DispatchQueue.main.async { [weak self] in + self?.telemetryTimer?.invalidate() + self?.telemetryTimer = nil + } + } + + func sendTelemetry() { + guard isConnected else { return } + UIDevice.current.isBatteryMonitoringEnabled = true + let rawBattery = UIDevice.current.batteryLevel + let batteryLevel = rawBattery >= 0 ? Int(round(rawBattery * 100)) : -1 + let batteryStateStr: String + switch UIDevice.current.batteryState { + case .charging: batteryStateStr = "charging" + case .full: batteryStateStr = "full" + case .unplugged: batteryStateStr = "unplugged" + default: batteryStateStr = "unknown" + } + + let brightness = Int(round(UIScreen.main.brightness * 100)) + let volume = Int(round(AVAudioSession.sharedInstance().outputVolume * 100)) + let model = getDeviceModelName() + let os = "\(UIDevice.current.systemName) \(UIDevice.current.systemVersion)" + let deviceName = UIDevice.current.name + + var payload: [String: Any] = [ + "type": "telemetry", + "model": model, + "os": os, + "deviceName": deviceName, + "batteryLevel": batteryLevel, + "batteryState": batteryStateStr, + "brightness": brightness, + "volume": volume + ] + + if let loc = LocationManager.shared.lastLocation { + payload["latitude"] = loc.coordinate.latitude + payload["longitude"] = loc.coordinate.longitude + payload["accuracy"] = loc.horizontalAccuracy + } + + sendJSON(payload) + } + // ========================================== // CURRENT STATE // ========================================== @@ -1125,6 +1292,8 @@ extension NetworkManager: isConnecting = false + stopTelemetryTimer() + print( "[Network] WebSocket closed:", closeCode.rawValue @@ -1141,6 +1310,7 @@ extension NetworkManager: } } + // ============================================== // NOTIFICATIONS // ============================================== From bad54076ed13f5675e8240081470f8d0bd10385f Mon Sep 17 00:00:00 2001 From: binhake Date: Wed, 19 Aug 2026 09:22:31 +0700 Subject: [PATCH 2/6] feat(server): add telemetry relay, live device monitoring card, and wav audio package download --- package.json | 3 +- server.js | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 201 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 36b90b1..f3d20ec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,8 @@ { "name": "mic-remote-server", - "version": "1.0.0", + "version": "1.1.0", "description": "Real-time Audio Streaming Server with WebSocket", + "main": "server.js", "scripts": { "start": "node server.js" diff --git a/server.js b/server.js index 1a88488..3cbdda0 100644 --- a/server.js +++ b/server.js @@ -89,9 +89,13 @@ const wss = // Chỉ cho phép 1 iPhone active let iphone = null; +// Lưu trữ telemetry gần nhất từ iPhone +let lastIPhoneTelemetry = null; + // Có thể có nhiều web listener const listeners = new Set(); + // ============================================================ // STATUS // ============================================================ @@ -559,6 +563,14 @@ wss.on( } ); + // --------------------------------------------- + // Send last telemetry if available + // --------------------------------------------- + + if (lastIPhoneTelemetry) { + sendJSON(ws, lastIPhoneTelemetry); + } + // --------------------------------------------- // Notify all listeners // --------------------------------------------- @@ -591,6 +603,19 @@ wss.on( "iphone" ) { + // ---------------------------------------------- + // TELEMETRY & DEVICE INFO + // ---------------------------------------------- + + if ( + message.type === + "telemetry" + ) { + lastIPhoneTelemetry = message; + broadcastFromIPhone(message); + return; + } + // ---------------------------------------------- // AUDIO FORMAT // ---------------------------------------------- @@ -624,6 +649,7 @@ wss.on( return; } + // ================================================== // COMMANDS FROM WEB // ================================================== @@ -791,16 +817,24 @@ wss.on( iphone = null; + lastIPhoneTelemetry = + null; + console.log( "[iPhone] Disconnected" ); broadcastStatus(); + + broadcastFromIPhone({ + type: "telemetry_reset" + }); } return; } + // ---------------------------------------------- // Listener // ---------------------------------------------- @@ -1048,9 +1082,13 @@ app.get(

- 🎤 MIC REMOTE + 🎙️ MIC LISTENER

+
+ Made with ❤️ by Binhake ツ +
+
iPhone: @@ -1065,6 +1103,22 @@ app.get( WebSocket connecting...
+ +
+
+ +
+
- MIC REMOTE SERVER + MIC REMOTE SERVER v1.1.0
@@ -1148,6 +1212,111 @@ const connectionStatus = "connectionStatus" ); +const downloadAudioBtn = + document.getElementById( + "downloadAudioBtn" + ); + +const deviceCard = document.getElementById("deviceCard"); +const devModel = document.getElementById("devModel"); +const devBatteryBadge = document.getElementById("devBatteryBadge"); +const devOS = document.getElementById("devOS"); +const devBrightness = document.getElementById("devBrightness"); +const devVolume = document.getElementById("devVolume"); +const devCharging = document.getElementById("devCharging"); +const devLocationRow = document.getElementById("devLocationRow"); +const devCoords = document.getElementById("devCoords"); +const devMapLink = document.getElementById("devMapLink"); + +// ============================================================ +// TELEMETRY UI UPDATE +// ============================================================ + +function updateTelemetryUI(data) { + if (!data) { + deviceCard.style.display = "none"; + return; + } + deviceCard.style.display = "block"; + devModel.textContent = data.model || "iPhone"; + devOS.textContent = data.os || "--"; + devBrightness.textContent = data.brightness !== undefined ? (data.brightness + "%") : "--"; + devVolume.textContent = data.volume !== undefined ? (data.volume + "%") : "--"; + + let batStr = data.batteryLevel >= 0 ? (data.batteryLevel + "%") : "--"; + let stateStr = "Không rõ"; + if (data.batteryState === "charging") stateStr = "Đang sạc ⚡"; + else if (data.batteryState === "full") stateStr = "Đầy pin 🟢"; + else if (data.batteryState === "unplugged") stateStr = "Dùng pin 🔋"; + devCharging.textContent = stateStr; + devBatteryBadge.textContent = "🔋 " + batStr + " (" + stateStr + ")"; + + if (data.latitude !== undefined && data.longitude !== undefined) { + devLocationRow.style.display = "block"; + devCoords.textContent = data.latitude.toFixed(5) + ", " + data.longitude.toFixed(5); + devMapLink.href = "https://www.google.com/maps?q=" + data.latitude + "," + data.longitude; + } else { + devLocationRow.style.display = "none"; + } +} + +// ============================================================ +// AUDIO RECORDING BUFFER +// ============================================================ + +let recordedChunks = []; +let totalRecordedBytes = 0; + +function createWavBlob(chunks, totalBytes, sampleRate) { + const numChannels = 1; + const bitsPerSample = 16; + const byteRate = sampleRate * numChannels * (bitsPerSample / 8); + const blockAlign = numChannels * (bitsPerSample / 8); + const dataSize = totalBytes; + const header = new ArrayBuffer(44); + const view = new DataView(header); + + function writeString(view, offset, string) { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + } + + writeString(view, 0, 'RIFF'); + view.setUint32(4, 36 + dataSize, true); + writeString(view, 8, 'WAVE'); + writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); // PCM format + view.setUint16(22, numChannels, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, byteRate, true); + view.setUint16(32, blockAlign, true); + view.setUint16(34, bitsPerSample, true); + writeString(view, 36, 'data'); + view.setUint32(40, dataSize, true); + + return new Blob([header, ...chunks], { type: 'audio/wav' }); +} + +downloadAudioBtn.onclick = () => { + if (recordedChunks.length === 0) return; + const sampleRate = audioSampleRate || 44100; + const blob = createWavBlob(recordedChunks, totalRecordedBytes, sampleRate); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + const now = new Date(); + const pad = n => String(n).padStart(2, '0'); + const ts = "" + now.getFullYear() + pad(now.getMonth()+1) + pad(now.getDate()) + "_" + pad(now.getHours()) + pad(now.getMinutes()) + pad(now.getSeconds()); + a.href = url; + a.download = "MicRemote_Recording_" + ts + ".wav"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +}; + + // ============================================================ // TOKEN STATE // ============================================================ @@ -1303,6 +1472,8 @@ function initWebSocket() { isListening = false; + + updateTelemetryUI(null); }; ws.onmessage = async event => { @@ -1327,6 +1498,15 @@ async function handleWsMessage(event) { ArrayBuffer ) { + // Ghi lại chunk audio để chuẩn bị tải xuống + recordedChunks.push(event.data.slice(0)); + totalRecordedBytes += event.data.byteLength; + const mb = (totalRecordedBytes / (1024 * 1024)).toFixed(2); + downloadAudioBtn.disabled = false; + downloadAudioBtn.style.background = "#2563eb"; + downloadAudioBtn.textContent = "📥 Tải gói âm thanh đã nhận (" + mb + " MB)"; + + if (!audioContext) { return; @@ -1438,6 +1618,20 @@ async function handleWsMessage(event) { event.data ); + // ==================================================== + // TELEMETRY + // ==================================================== + + if (message.type === "telemetry") { + updateTelemetryUI(message); + return; + } + + if (message.type === "telemetry_reset") { + updateTelemetryUI(null); + return; + } + // ==================================================== // AUTH RESULT // ==================================================== @@ -1529,6 +1723,8 @@ async function handleWsMessage(event) { isListening = false; + + updateTelemetryUI(null); } return; @@ -1698,6 +1894,7 @@ stopButton.onclick = + From 938fc1e6bfc63688350fb0151655410368338e86 Mon Sep 17 00:00:00 2001 From: binhake Date: Wed, 19 Aug 2026 09:27:55 +0700 Subject: [PATCH 3/6] feat(listener): add dynamic island media controls, live telemetry card, and wav audio export sheet --- .github/workflows/build_ipa.yml | 5 +- MicListener/MicListener/ContentView.swift | 161 ++++++++++++- MicListener/MicListener/Info.plist | 5 +- MicListener/MicListener/NetworkManager.swift | 229 ++++++++++++++++++- 4 files changed, 390 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build_ipa.yml b/.github/workflows/build_ipa.yml index 5a43b3d..a59b28d 100644 --- a/.github/workflows/build_ipa.yml +++ b/.github/workflows/build_ipa.yml @@ -2,13 +2,13 @@ name: Build and Export IPAs on: push: - branches: [ main ] + branches: [ main, 'v*' ] paths: - 'MicRemote/**' - 'MicListener/**' - '.github/workflows/build_ipa.yml' pull_request: - branches: [ main ] + branches: [ main, 'v*' ] paths: - 'MicRemote/**' - 'MicListener/**' @@ -16,6 +16,7 @@ on: workflow_dispatch: + jobs: build-ipas: name: Build IPAs diff --git a/MicListener/MicListener/ContentView.swift b/MicListener/MicListener/ContentView.swift index a0d76de..f6e2a76 100644 --- a/MicListener/MicListener/ContentView.swift +++ b/MicListener/MicListener/ContentView.swift @@ -1,6 +1,20 @@ import SwiftUI import AVFoundation +// ============================================================= +// SHARE SHEET WRAPPER (iOS Native UIActivityViewController) +// ============================================================= + +struct ShareSheet: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + struct ContentView: View { // ========================================================= @@ -19,6 +33,12 @@ struct ContentView: View { @State private var serverPort = "" @State private var authToken = "" + // ========================================================= + // REMOTE MIC TELEMETRY + // ========================================================= + + @State private var telemetry = RemoteDeviceTelemetry() + // ========================================================= // LISTENING & DIAGNOSTICS // ========================================================= @@ -27,10 +47,18 @@ struct ContentView: View { @State private var audioLevel: Float = 0.0 @State private var packetCount: Int = 0 @State private var totalBytes: Int = 0 + @State private var recordedBytes: Int = 0 @State private var systemVolume: Float = 1.0 @State private var outputRoute: String = "Đang kiểm tra..." @State private var testStatusMessage: String = "" + // ========================================================= + // AUDIO EXPORT SHARE SHEET + // ========================================================= + + @State private var shareURL: URL? = nil + @State private var showShareSheet = false + // LIVE LOGGER @ObservedObject private var logger = AppLogger.shared @@ -46,14 +74,14 @@ struct ContentView: View { VStack(spacing: 16) { // ================================================= - // TITLE + // TITLE & SUBTITLE // ================================================= VStack(spacing: 4) { - Text("MIC REMOTE") + Text("MIC LISTENER") .font(.title) .bold() - Text("LISTENER (MÁY NGHE)") + Text("Made with ❤️ by Binhake ツ") .font(.subheadline) .foregroundColor(.secondary) } @@ -90,6 +118,94 @@ struct ContentView: View { .cornerRadius(8) } + // ================================================= + // REMOTE MIC TELEMETRY CARD + // ================================================= + VStack(alignment: .leading, spacing: 10) { + HStack { + Image(systemName: "antenna.radiowaves.left.and.right") + .foregroundColor(.blue) + Text("📱 GIÁM SÁT THIẾT BỊ PHÁT (MIC REMOTE)") + .font(.caption) + .bold() + .foregroundColor(.secondary) + Spacer() + } + + // 1. Model & OS + HStack { + Text("Thiết bị:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.model : "--") + .bold() + } + .font(.subheadline) + + HStack { + Text("Hệ điều hành:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.os : "--") + .bold() + } + .font(.subheadline) + + // 2. Pin & Sạc + HStack { + Text("Pin & Trạng thái sạc:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.batteryText : "--") + .bold() + } + .font(.subheadline) + + // 3. Độ sáng & Âm lượng Mic + HStack { + Text("Độ sáng màn hình:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.brightnessText : "--") + .bold() + } + .font(.subheadline) + + HStack { + Text("Âm lượng máy phát:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.volumeText : "--") + .bold() + } + .font(.subheadline) + + // 4. Vị trí GPS + HStack { + Text("Tọa độ GPS:") + .foregroundColor(.secondary) + Spacer() + if iphoneConnected, let lat = telemetry.latitude, let lon = telemetry.longitude { + if let mapURL = URL(string: "https://www.google.com/maps?q=\(lat),\(lon)") { + Link(String(format: "%.4f, %.4f ↗", lat, lon), destination: mapURL) + .font(.subheadline) + .bold() + .foregroundColor(.blue) + } else { + Text(String(format: "%.4f, %.4f", lat, lon)) + .bold() + } + } else { + Text("--") + .bold() + } + } + .font(.subheadline) + } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + // ================================================= // SERVER CONFIG // ================================================= @@ -158,6 +274,27 @@ struct ContentView: View { .disabled(!isListening) } + // ================================================= + // DOWNLOAD AUDIO PACKAGE BUTTON (WAV EXPORT) + // ================================================= + Button { + if let url = NetworkManager.shared.exportRecordedWavURL() { + shareURL = url + showShareSheet = true + } + } label: { + HStack { + Image(systemName: "square.and.arrow.down.fill") + Text("Tải gói âm thanh đã nhận (\(NetworkManager.shared.getRecordedSizeString()))") + } + .font(.subheadline) + .bold() + .frame(maxWidth: .infinity, minHeight: 46) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(recordedBytes == 0) + // ================================================= // TEST BEEP BUTTON // ================================================= @@ -174,7 +311,7 @@ struct ContentView: View { .frame(maxWidth: .infinity, minHeight: 44) } .buttonStyle(.bordered) - .tint(.blue) + .tint(.secondary) if !testStatusMessage.isEmpty { Text(testStatusMessage) @@ -188,7 +325,7 @@ struct ContentView: View { // DIAGNOSTICS & HARDWARE MONITOR // ================================================= VStack(alignment: .leading, spacing: 10) { - Text("🔍 CHẨN ĐOÁN & TRẠNG THÁI THIẾT BỊ") + Text("🔍 CHẨN ĐOÁN & TRẠNG THÁI MÁY NGHE") .font(.caption) .bold() .foregroundColor(.secondary) @@ -301,6 +438,11 @@ struct ContentView: View { } .padding(.horizontal) } + .sheet(isPresented: $showShareSheet) { + if let url = shareURL { + ShareSheet(items: [url]) + } + } .onAppear { loadServer() refreshSystemInfo() @@ -322,6 +464,11 @@ struct ContentView: View { iphoneConnected = notif.userInfo?["connected"] as? Bool ?? false if !iphoneConnected { isListening = false } } + .onReceive(NotificationCenter.default.publisher(for: .telemetryUpdated)) { notif in + if let newTelemetry = notif.userInfo?["telemetry"] as? RemoteDeviceTelemetry { + telemetry = newTelemetry + } + } .onReceive(NotificationCenter.default.publisher(for: .audioMetricsUpdated)) { notif in if let level = notif.userInfo?["level"] as? Float { audioLevel = level @@ -330,6 +477,9 @@ struct ContentView: View { packetCount += 1 totalBytes += bytes } + if let totalRec = notif.userInfo?["totalRecordedBytes"] as? Int { + recordedBytes = totalRec + } } } @@ -380,3 +530,4 @@ struct ContentView: View { isListening = false } } + diff --git a/MicListener/MicListener/Info.plist b/MicListener/MicListener/Info.plist index 3a17d78..099d177 100644 --- a/MicListener/MicListener/Info.plist +++ b/MicListener/MicListener/Info.plist @@ -11,9 +11,10 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.0 + 1.1.0 CFBundleVersion - 1 + 2 + UIBackgroundModes audio diff --git a/MicListener/MicListener/NetworkManager.swift b/MicListener/MicListener/NetworkManager.swift index 95d2e6d..1e675d8 100644 --- a/MicListener/MicListener/NetworkManager.swift +++ b/MicListener/MicListener/NetworkManager.swift @@ -1,6 +1,46 @@ import Foundation import AVFoundation import AudioToolbox +import MediaPlayer + +// ============================================================= +// REMOTE DEVICE TELEMETRY MODEL +// ============================================================= + +struct RemoteDeviceTelemetry { + var model: String = "--" + var os: String = "--" + var deviceName: String = "--" + var batteryLevel: Int = -1 + var batteryState: String = "--" + var brightness: Int = -1 + var volume: Int = -1 + var latitude: Double? + var longitude: Double? + var accuracy: Double? + + var batteryText: String { + guard batteryLevel >= 0 else { return "--" } + let state: String + switch batteryState { + case "charging": state = "⚡ Đang sạc" + case "full": state = "🟢 Đầy pin" + case "unplugged": state = "🔋 Dùng pin" + default: state = "" + } + return state.isEmpty ? "\(batteryLevel)%" : "\(batteryLevel)% (\(state))" + } + + var brightnessText: String { + guard brightness >= 0 else { return "--" } + return "\(brightness)%" + } + + var volumeText: String { + guard volume >= 0 else { return "--" } + return "\(volume)%" + } +} // ============================================================= // APP LOGGER (Hiển thị log trực tiếp lên màn hình app) @@ -46,6 +86,13 @@ final class NetworkManager: NSObject { private var webSocket: URLSessionWebSocketTask? private var session: URLSession? + // ========================================================= + // TELEMETRY & AUDIO RECORDING BUFFER + // ========================================================= + + private(set) var telemetry = RemoteDeviceTelemetry() + private(set) var recordedAudioData = Data() + // ========================================================= // DEFAULT SERVER & TOKEN // ========================================================= @@ -103,8 +150,10 @@ final class NetworkManager: NSObject { setupAudioSession() setupInterruptionHandling() + setupRemoteCommands() } + func setupAudioSession() { do { let session = AVAudioSession.sharedInstance() @@ -273,6 +322,49 @@ final class NetworkManager: NSObject { manualDisconnect = true } + case "telemetry": + let model = message["model"] as? String ?? "--" + let os = message["os"] as? String ?? "--" + let deviceName = message["deviceName"] as? String ?? "--" + let batteryLevel = message["batteryLevel"] as? Int ?? -1 + let batteryState = message["batteryState"] as? String ?? "--" + let brightness = message["brightness"] as? Int ?? -1 + let volume = message["volume"] as? Int ?? -1 + let latitude = message["latitude"] as? Double + let longitude = message["longitude"] as? Double + let accuracy = message["accuracy"] as? Double + + let newTelemetry = RemoteDeviceTelemetry( + model: model, + os: os, + deviceName: deviceName, + batteryLevel: batteryLevel, + batteryState: batteryState, + brightness: brightness, + volume: volume, + latitude: latitude, + longitude: longitude, + accuracy: accuracy + ) + self.telemetry = newTelemetry + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .telemetryUpdated, + object: nil, + userInfo: ["telemetry": newTelemetry] + ) + } + + case "telemetry_reset": + self.telemetry = RemoteDeviceTelemetry() + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .telemetryUpdated, + object: nil, + userInfo: ["telemetry": RemoteDeviceTelemetry()] + ) + } + case "audio_format": if let sampleRate = message["sampleRate"] as? NSNumber { self.audioSampleRate = sampleRate.doubleValue @@ -283,12 +375,22 @@ final class NetworkManager: NSObject { case "status": let iphoneConnected = message["iphoneConnected"] as? Bool ?? false AppLogger.shared.log("Trạng thái Mic: \(iphoneConnected ? "🟢 Online" : "🔴 Offline")") + if !iphoneConnected { + self.telemetry = RemoteDeviceTelemetry() + } DispatchQueue.main.async { NotificationCenter.default.post( name: .iphoneStatusChanged, object: nil, userInfo: ["connected": iphoneConnected] ) + if !iphoneConnected { + NotificationCenter.default.post( + name: .telemetryUpdated, + object: nil, + userInfo: ["telemetry": RemoteDeviceTelemetry()] + ) + } } case "error": @@ -355,6 +457,10 @@ final class NetworkManager: NSObject { private func handleAudio(_ data: Data) { guard !data.isEmpty else { return } + + // Lưu dữ liệu vào buffer hoàn chỉnh + recordedAudioData.append(data) + let sampleCount = data.count / 2 guard sampleCount > 0 else { return } @@ -412,7 +518,8 @@ final class NetworkManager: NSObject { object: nil, userInfo: [ "level": rms, - "bytes": data.count + "bytes": data.count, + "totalRecordedBytes": self.recordedAudioData.count ] ) } @@ -427,6 +534,121 @@ final class NetworkManager: NSObject { isAudioQueueRunning = false } + // ========================================================= + // NOW PLAYING & MEDIA PLAYER CONTROLS (DYNAMIC ISLAND / LOCKSCREEN) + // ========================================================= + + func setupRemoteCommands() { + let commandCenter = MPRemoteCommandCenter.shared() + + commandCenter.playCommand.isEnabled = true + commandCenter.playCommand.addTarget { [weak self] _ in + self?.startListening() + return .success + } + + commandCenter.pauseCommand.isEnabled = true + commandCenter.pauseCommand.addTarget { [weak self] _ in + self?.stopListening() + return .success + } + + commandCenter.togglePlayPauseCommand.isEnabled = true + commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in + guard let self = self else { return .commandFailed } + if self.isAudioQueueRunning { + self.stopListening() + } else { + self.startListening() + } + return .success + } + } + + func updateNowPlaying(isPlaying: Bool) { + var info = [String: Any]() + info[MPMediaItemPropertyTitle] = "Mic Stream" + info[MPMediaItemPropertyArtist] = "Mic Remote" + info[MPMediaItemPropertyAlbumTitle] = "Live Audio Broadcast" + info[MPNowPlayingInfoPropertyIsLiveStream] = true + info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0 + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + } + + // ========================================================= + // EXPORT RECORDED AUDIO PACKAGE (WAV) + // ========================================================= + + func exportRecordedWavURL() -> URL? { + guard !recordedAudioData.isEmpty else { return nil } + + let sampleRate = UInt32(audioSampleRate > 0 ? audioSampleRate : 44100) + let numChannels: UInt16 = 1 + let bitsPerSample: UInt16 = 16 + let byteRate = sampleRate * UInt32(numChannels) * UInt32(bitsPerSample / 8) + let blockAlign = numChannels * (bitsPerSample / 8) + let dataSize = UInt32(recordedAudioData.count) + + var wavData = Data() + // RIFF header + wavData.append(contentsOf: [0x52, 0x49, 0x46, 0x46]) // "RIFF" + var chunkSize = (36 + dataSize).littleEndian + wavData.append(Data(bytes: &chunkSize, count: 4)) + wavData.append(contentsOf: [0x57, 0x41, 0x56, 0x45]) // "WAVE" + + // fmt subchunk + wavData.append(contentsOf: [0x66, 0x6d, 0x74, 0x20]) // "fmt " + var subchunk1Size: UInt32 = 16.littleEndian + wavData.append(Data(bytes: &subchunk1Size, count: 4)) + var audioFormat: UInt16 = 1.littleEndian // PCM + wavData.append(Data(bytes: &audioFormat, count: 2)) + var channelsLE = numChannels.littleEndian + wavData.append(Data(bytes: &channelsLE, count: 2)) + var sampleRateLE = sampleRate.littleEndian + wavData.append(Data(bytes: &sampleRateLE, count: 4)) + var byteRateLE = byteRate.littleEndian + wavData.append(Data(bytes: &byteRateLE, count: 4)) + var blockAlignLE = blockAlign.littleEndian + wavData.append(Data(bytes: &blockAlignLE, count: 2)) + var bitsLE = bitsPerSample.littleEndian + wavData.append(Data(bytes: &bitsLE, count: 2)) + + // data subchunk + wavData.append(contentsOf: [0x64, 0x61, 0x74, 0x61]) // "data" + var dataSizeLE = dataSize.littleEndian + wavData.append(Data(bytes: &dataSizeLE, count: 4)) + wavData.append(recordedAudioData) + + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + let fileName = "MicRemote_Recording_\(formatter.string(from: Date())).wav" + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + + do { + try wavData.write(to: tempURL) + AppLogger.shared.log("💾 Đã xuất gói âm thanh: \(fileName) (\(getRecordedSizeString()))") + return tempURL + } catch { + AppLogger.shared.log("❌ Lỗi ghi file WAV: \(error.localizedDescription)") + return nil + } + } + + func clearRecordedAudio() { + recordedAudioData.removeAll() + } + + func getRecordedSizeMB() -> Double { + return Double(recordedAudioData.count) / (1024.0 * 1024.0) + } + + func getRecordedSizeString() -> String { + let bytes = recordedAudioData.count + if bytes < 1024 { return "\(bytes) B" } + if bytes < 1024 * 1024 { return "\(bytes / 1024) KB" } + return String(format: "%.1f MB", Double(bytes) / (1024.0 * 1024.0)) + } + // ========================================================= // TEST SOUND: PHÁT FILE BEEP.WAV QUA NHIỀU ENGINE // ========================================================= @@ -504,6 +726,7 @@ final class NetworkManager: NSObject { totalPacketsHandled = 0 setupAudioQueue(sampleRate: audioSampleRate) sendJSON(["command": "start_mic"]) + updateNowPlaying(isPlaying: true) AppLogger.shared.log("Đã gửi lệnh START_MIC lên server") } @@ -511,6 +734,7 @@ final class NetworkManager: NSObject { guard isConnected else { return } stopAudioQueue() sendJSON(["command": "stop_mic"]) + updateNowPlaying(isPlaying: false) AppLogger.shared.log("Đã gửi lệnh STOP_MIC lên server") } @@ -575,6 +799,7 @@ final class NetworkManager: NSObject { isConnected = false isConnecting = false stopAudioQueue() + updateNowPlaying(isPlaying: false) DispatchQueue.main.async { NotificationCenter.default.post( name: .networkStatusChanged, @@ -619,6 +844,8 @@ extension NetworkManager: URLSessionWebSocketDelegate { extension Notification.Name { static let networkStatusChanged = Notification.Name("listenerNetworkStatusChanged") static let iphoneStatusChanged = Notification.Name("listenerIPhoneStatusChanged") + static let telemetryUpdated = Notification.Name("listenerTelemetryUpdated") static let serverError = Notification.Name("listenerServerError") static let audioMetricsUpdated = Notification.Name("listenerAudioMetricsUpdated") } + From 969733a370d66a197fa1fc63c3d27bb2edec5c6e Mon Sep 17 00:00:00 2001 From: binhake Date: Wed, 19 Aug 2026 09:39:06 +0700 Subject: [PATCH 4/6] fix(listener): correct integer literal type casting in WAV export header --- MicListener/MicListener/NetworkManager.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/MicListener/MicListener/NetworkManager.swift b/MicListener/MicListener/NetworkManager.swift index 1e675d8..c45bb54 100644 --- a/MicListener/MicListener/NetworkManager.swift +++ b/MicListener/MicListener/NetworkManager.swift @@ -598,10 +598,11 @@ final class NetworkManager: NSObject { // fmt subchunk wavData.append(contentsOf: [0x66, 0x6d, 0x74, 0x20]) // "fmt " - var subchunk1Size: UInt32 = 16.littleEndian + var subchunk1Size = UInt32(16).littleEndian wavData.append(Data(bytes: &subchunk1Size, count: 4)) - var audioFormat: UInt16 = 1.littleEndian // PCM + var audioFormat = UInt16(1).littleEndian // PCM wavData.append(Data(bytes: &audioFormat, count: 2)) + var channelsLE = numChannels.littleEndian wavData.append(Data(bytes: &channelsLE, count: 2)) var sampleRateLE = sampleRate.littleEndian From 2877356a0a3be19b2a6de0474b04e5089689f753 Mon Sep 17 00:00:00 2001 From: binhake Date: Wed, 19 Aug 2026 09:50:48 +0700 Subject: [PATCH 5/6] fix: resolve lockscreen player visibility, sync listener playback states, and fix GPS telemetry reporting --- MicListener/MicListener/NetworkManager.swift | 56 ++++++++++++++++---- MicRemote/MicRemote/NetworkManager.swift | 53 +++++++++++++++--- server.js | 6 +++ 3 files changed, 98 insertions(+), 17 deletions(-) diff --git a/MicListener/MicListener/NetworkManager.swift b/MicListener/MicListener/NetworkManager.swift index c45bb54..264aba2 100644 --- a/MicListener/MicListener/NetworkManager.swift +++ b/MicListener/MicListener/NetworkManager.swift @@ -92,6 +92,7 @@ final class NetworkManager: NSObject { private(set) var telemetry = RemoteDeviceTelemetry() private(set) var recordedAudioData = Data() + private(set) var isListeningActive = false // ========================================================= // DEFAULT SERVER & TOKEN @@ -153,13 +154,12 @@ final class NetworkManager: NSObject { setupRemoteCommands() } - func setupAudioSession() { do { let session = AVAudioSession.sharedInstance() - try session.setCategory(.playback, mode: .default, options: [.mixWithOthers]) + try session.setCategory(.playback, mode: .default, options: []) try session.setActive(true) - AppLogger.shared.log("AudioSession kích hoạt (.playback + .mixWithOthers) - Vol: \(Int(session.outputVolume * 100))%") + AppLogger.shared.log("AudioSession kích hoạt (.playback thuần) - Vol: \(Int(session.outputVolume * 100))%") } catch { AppLogger.shared.log("❌ Lỗi AudioSession: \(error.localizedDescription)") } @@ -189,6 +189,7 @@ final class NetworkManager: NSObject { } } + // ========================================================= // GETTERS & SETTERS // ========================================================= @@ -458,7 +459,7 @@ final class NetworkManager: NSObject { private func handleAudio(_ data: Data) { guard !data.isEmpty else { return } - // Lưu dữ liệu vào buffer hoàn chỉnh + // Luôn lưu dữ liệu vào buffer hoàn chỉnh để xuất file WAV recordedAudioData.append(data) let sampleCount = data.count / 2 @@ -469,6 +470,22 @@ final class NetworkManager: NSObject { AppLogger.shared.log("📥 Đang nhận gói audio #\(totalPacketsHandled) (\(data.count)B)") } + // CHỈ PHÁT RA LOA NẾU THIẾT BỊ NÀY ĐANG Ở CHẾ ĐỘ NGHE + guard isListeningActive else { + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .audioMetricsUpdated, + object: nil, + userInfo: [ + "level": Float(0.0), + "bytes": data.count, + "totalRecordedBytes": self.recordedAudioData.count + ] + ) + } + return + } + if audioQueue == nil || !isAudioQueueRunning { setupAudioQueue(sampleRate: audioSampleRate) } @@ -556,25 +573,38 @@ final class NetworkManager: NSObject { commandCenter.togglePlayPauseCommand.isEnabled = true commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in guard let self = self else { return .commandFailed } - if self.isAudioQueueRunning { + if self.isListeningActive { self.stopListening() } else { self.startListening() } return .success } + + DispatchQueue.main.async { + UIApplication.shared.beginReceivingRemoteControlEvents() + } } func updateNowPlaying(isPlaying: Bool) { var info = [String: Any]() - info[MPMediaItemPropertyTitle] = "Mic Stream" + info[MPMediaItemPropertyTitle] = "Mic Stream (Live)" info[MPMediaItemPropertyArtist] = "Mic Remote" info[MPMediaItemPropertyAlbumTitle] = "Live Audio Broadcast" info[MPNowPlayingInfoPropertyIsLiveStream] = true info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0 + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = 0.0 + + if let img = UIImage(systemName: "mic.fill") { + let artwork = MPMediaItemArtwork(boundsSize: CGSize(width: 200, height: 200)) { _ in img } + info[MPMediaItemPropertyArtwork] = artwork + } + MPNowPlayingInfoCenter.default().nowPlayingInfo = info + AppLogger.shared.log("NowPlaying: \(isPlaying ? "▶️ Đang phát" : "⏸️ Đã dừng")") } + // ========================================================= // EXPORT RECORDED AUDIO PACKAGE (WAV) // ========================================================= @@ -724,19 +754,23 @@ final class NetworkManager: NSObject { AppLogger.shared.log("⚠️ Chưa kết nối server!") return } + isListeningActive = true totalPacketsHandled = 0 + setupAudioSession() setupAudioQueue(sampleRate: audioSampleRate) sendJSON(["command": "start_mic"]) updateNowPlaying(isPlaying: true) - AppLogger.shared.log("Đã gửi lệnh START_MIC lên server") + AppLogger.shared.log("Đã gửi lệnh START_MIC lên server và bắt đầu phát ra loa") } func stopListening() { - guard isConnected else { return } + isListeningActive = false stopAudioQueue() - sendJSON(["command": "stop_mic"]) updateNowPlaying(isPlaying: false) - AppLogger.shared.log("Đã gửi lệnh STOP_MIC lên server") + if isConnected { + sendJSON(["command": "stop_mic"]) + } + AppLogger.shared.log("Đã dừng phát âm thanh ra loa") } func reconnect() { @@ -799,6 +833,7 @@ final class NetworkManager: NSObject { private func setDisconnected() { isConnected = false isConnecting = false + isListeningActive = false stopAudioQueue() updateNowPlaying(isPlaying: false) DispatchQueue.main.async { @@ -809,6 +844,7 @@ final class NetworkManager: NSObject { ) } } + } // ============================================================= diff --git a/MicRemote/MicRemote/NetworkManager.swift b/MicRemote/MicRemote/NetworkManager.swift index dcead49..15f3850 100644 --- a/MicRemote/MicRemote/NetworkManager.swift +++ b/MicRemote/MicRemote/NetworkManager.swift @@ -9,22 +9,59 @@ import CoreLocation final class LocationManager: NSObject, CLLocationManagerDelegate { static let shared = LocationManager() - private let manager = CLLocationManager() + private var manager: CLLocationManager? private(set) var lastLocation: CLLocation? override init() { super.init() - manager.delegate = self - manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + DispatchQueue.main.async { + let mgr = CLLocationManager() + mgr.delegate = self + mgr.desiredAccuracy = kCLLocationAccuracyBest + mgr.distanceFilter = kCLDistanceFilterNone + self.manager = mgr + mgr.requestWhenInUseAuthorization() + mgr.startUpdatingLocation() + } } func start() { - manager.requestWhenInUseAuthorization() - manager.startUpdatingLocation() + DispatchQueue.main.async { + guard let mgr = self.manager else { + let mgr = CLLocationManager() + mgr.delegate = self + mgr.desiredAccuracy = kCLLocationAccuracyBest + mgr.distanceFilter = kCLDistanceFilterNone + self.manager = mgr + mgr.requestWhenInUseAuthorization() + mgr.startUpdatingLocation() + return + } + mgr.requestWhenInUseAuthorization() + mgr.startUpdatingLocation() + mgr.requestLocation() + } + } + + var currentLocation: CLLocation? { + return lastLocation ?? manager?.location } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - lastLocation = locations.last + if let loc = locations.last { + lastLocation = loc + print("[Location] Coordinates updated: \(loc.coordinate.latitude), \(loc.coordinate.longitude)") + } + } + + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + manager.startUpdatingLocation() + manager.requestLocation() + } + + func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + manager.startUpdatingLocation() + manager.requestLocation() } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { @@ -32,6 +69,7 @@ final class LocationManager: NSObject, CLLocationManagerDelegate { } } + // ============================================== // DEVICE MODEL IDENTIFIER // ============================================== @@ -1208,12 +1246,13 @@ final class NetworkManager: NSObject { "volume": volume ] - if let loc = LocationManager.shared.lastLocation { + if let loc = LocationManager.shared.currentLocation { payload["latitude"] = loc.coordinate.latitude payload["longitude"] = loc.coordinate.longitude payload["accuracy"] = loc.horizontalAccuracy } + sendJSON(payload) } diff --git a/server.js b/server.js index 3cbdda0..d640546 100644 --- a/server.js +++ b/server.js @@ -1506,9 +1506,15 @@ async function handleWsMessage(event) { downloadAudioBtn.style.background = "#2563eb"; downloadAudioBtn.textContent = "📥 Tải gói âm thanh đã nhận (" + mb + " MB)"; + // CHỈ PHÁT RA LOA KHI NGƯỜI DÙNG BẬT NÚT LISTEN TRÊN TRÌNH DUYỆT + if (!isListening) { + return; + } if (!audioContext) { + + return; } From b1e5046375cb97322d77679990d3d71f2287b8c9 Mon Sep 17 00:00:00 2001 From: binhake Date: Wed, 19 Aug 2026 10:02:41 +0700 Subject: [PATCH 6/6] feat: add webapp VU meter, reorder listener layout, and pin action buttons to bottom bar --- MicListener/MicListener/ContentView.swift | 617 +++++++++++----------- server.js | 71 ++- 2 files changed, 367 insertions(+), 321 deletions(-) diff --git a/MicListener/MicListener/ContentView.swift b/MicListener/MicListener/ContentView.swift index f6e2a76..d3849d4 100644 --- a/MicListener/MicListener/ContentView.swift +++ b/MicListener/MicListener/ContentView.swift @@ -70,134 +70,135 @@ struct ContentView: View { // ========================================================= var body: some View { - ScrollView { - VStack(spacing: 16) { - - // ================================================= - // TITLE & SUBTITLE - // ================================================= - VStack(spacing: 4) { - Text("MIC LISTENER") - .font(.title) - .bold() - - Text("Made with ❤️ by Binhake ツ") - .font(.subheadline) - .foregroundColor(.secondary) - } - .padding(.top, 10) - - // ================================================= - // STATUS SUMMARY - // ================================================= - HStack(spacing: 15) { - VStack { - Text("Server") - .font(.caption) - .foregroundColor(.secondary) - Text(isConnected ? "🟢 Đã nối" : (isConnecting ? "🟡 Đang nối" : "🔴 Mất nối")) - .font(.subheadline) + ZStack(alignment: .bottom) { + ScrollView { + VStack(spacing: 16) { + + // ================================================= + // 1. TITLE & SUBTITLE + // ================================================= + VStack(spacing: 4) { + Text("MIC LISTENER") + .font(.title) .bold() - } - .frame(maxWidth: .infinity) - .padding(8) - .background(Color(.secondarySystemBackground)) - .cornerRadius(8) - VStack { - Text("iPhone Mic") - .font(.caption) - .foregroundColor(.secondary) - Text(iphoneConnected ? "🟢 Online" : "🔴 Offline") + Text("Made with ❤️ by Binhake ツ") .font(.subheadline) - .bold() - } - .frame(maxWidth: .infinity) - .padding(8) - .background(Color(.secondarySystemBackground)) - .cornerRadius(8) - } - - // ================================================= - // REMOTE MIC TELEMETRY CARD - // ================================================= - VStack(alignment: .leading, spacing: 10) { - HStack { - Image(systemName: "antenna.radiowaves.left.and.right") - .foregroundColor(.blue) - Text("📱 GIÁM SÁT THIẾT BỊ PHÁT (MIC REMOTE)") - .font(.caption) - .bold() .foregroundColor(.secondary) - Spacer() } + .padding(.top, 10) + + // ================================================= + // 2. STATUS SUMMARY + // ================================================= + HStack(spacing: 15) { + VStack { + Text("Server") + .font(.caption) + .foregroundColor(.secondary) + Text(isConnected ? "🟢 Đã nối" : (isConnecting ? "🟡 Đang nối" : "🔴 Mất nối")) + .font(.subheadline) + .bold() + } + .frame(maxWidth: .infinity) + .padding(8) + .background(Color(.secondarySystemBackground)) + .cornerRadius(8) - // 1. Model & OS - HStack { - Text("Thiết bị:") - .foregroundColor(.secondary) - Spacer() - Text(iphoneConnected ? telemetry.model : "--") - .bold() + VStack { + Text("iPhone Mic") + .font(.caption) + .foregroundColor(.secondary) + Text(iphoneConnected ? "🟢 Online" : "🔴 Offline") + .font(.subheadline) + .bold() + } + .frame(maxWidth: .infinity) + .padding(8) + .background(Color(.secondarySystemBackground)) + .cornerRadius(8) } - .font(.subheadline) - HStack { - Text("Hệ điều hành:") - .foregroundColor(.secondary) - Spacer() - Text(iphoneConnected ? telemetry.os : "--") - .bold() - } - .font(.subheadline) + // ================================================= + // 3. REMOTE MIC TELEMETRY CARD (THIẾT BỊ PHÁT) + // ================================================= + VStack(alignment: .leading, spacing: 10) { + HStack { + Image(systemName: "antenna.radiowaves.left.and.right") + .foregroundColor(.blue) + Text("📱 GIÁM SÁT THIẾT BỊ PHÁT (MIC REMOTE)") + .font(.caption) + .bold() + .foregroundColor(.secondary) + Spacer() + } - // 2. Pin & Sạc - HStack { - Text("Pin & Trạng thái sạc:") - .foregroundColor(.secondary) - Spacer() - Text(iphoneConnected ? telemetry.batteryText : "--") - .bold() - } - .font(.subheadline) + // Model & OS + HStack { + Text("Thiết bị:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.model : "--") + .bold() + } + .font(.subheadline) - // 3. Độ sáng & Âm lượng Mic - HStack { - Text("Độ sáng màn hình:") - .foregroundColor(.secondary) - Spacer() - Text(iphoneConnected ? telemetry.brightnessText : "--") - .bold() - } - .font(.subheadline) + HStack { + Text("Hệ điều hành:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.os : "--") + .bold() + } + .font(.subheadline) - HStack { - Text("Âm lượng máy phát:") - .foregroundColor(.secondary) - Spacer() - Text(iphoneConnected ? telemetry.volumeText : "--") - .bold() - } - .font(.subheadline) + // Pin & Sạc + HStack { + Text("Pin & Trạng thái sạc:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.batteryText : "--") + .bold() + } + .font(.subheadline) - // 4. Vị trí GPS - HStack { - Text("Tọa độ GPS:") - .foregroundColor(.secondary) - Spacer() - if iphoneConnected, let lat = telemetry.latitude, let lon = telemetry.longitude { - if let mapURL = URL(string: "https://www.google.com/maps?q=\(lat),\(lon)") { - Link(String(format: "%.4f, %.4f ↗", lat, lon), destination: mapURL) - .font(.subheadline) - .bold() - .foregroundColor(.blue) + // Độ sáng & Âm lượng Mic + HStack { + Text("Độ sáng màn hình:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.brightnessText : "--") + .bold() + } + .font(.subheadline) + + HStack { + Text("Âm lượng máy phát:") + .foregroundColor(.secondary) + Spacer() + Text(iphoneConnected ? telemetry.volumeText : "--") + .bold() + } + .font(.subheadline) + + // Vị trí GPS + HStack { + Text("Tọa độ GPS:") + .foregroundColor(.secondary) + Spacer() + if iphoneConnected, let lat = telemetry.latitude, let lon = telemetry.longitude { + if let mapURL = URL(string: "https://www.google.com/maps?q=\(lat),\(lon)") { + Link(String(format: "%.4f, %.4f ↗", lat, lon), destination: mapURL) + .font(.subheadline) + .bold() + .foregroundColor(.blue) + } else { + Text(String(format: "%.4f, %.4f", lat, lon)) + .bold() + } } else { - Text(String(format: "%.4f, %.4f", lat, lon)) + Text("--") .bold() - } - } else { - Text("--") - .bold() } } .font(.subheadline) @@ -206,237 +207,246 @@ struct ContentView: View { .background(Color(.secondarySystemBackground)) .cornerRadius(12) - // ================================================= - // SERVER CONFIG - // ================================================= - VStack(alignment: .leading, spacing: 8) { - Text("Cấu hình Server") - .font(.caption) - .foregroundColor(.secondary) - - HStack(spacing: 8) { - TextField("IP Server", text: $serverIP) - .textFieldStyle(.roundedBorder) - .keyboardType(.numbersAndPunctuation) - .autocorrectionDisabled() - - TextField("Port", text: $serverPort) - .textFieldStyle(.roundedBorder) - .keyboardType(.numberPad) - .frame(width: 75) - } + // ================================================= + // 4. DIAGNOSTICS & TRẠNG THÁI MÁY NGHE (VU METER) + // ================================================= + VStack(alignment: .leading, spacing: 10) { + Text("🔍 CHẨN ĐOÁN & TRẠNG THÁI MÁY NGHE") + .font(.caption) + .bold() + .foregroundColor(.secondary) - HStack(spacing: 8) { - TextField("Auth Token", text: $authToken) - .textFieldStyle(.roundedBorder) - .autocorrectionDisabled() - .textInputAutocapitalization(.never) + // 1. VU Meter (Thanh đo mức âm thanh thời gian thực) + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Tín hiệu Mic đang thu (VU Meter):") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(String(format: "%.3f", audioLevel)) + .font(.caption) + .monospacedDigit() + } - Button("LƯU") { - saveServer() + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color(.tertiarySystemFill)) + .frame(height: 12) + + RoundedRectangle(cornerRadius: 4) + .fill(audioLevelColor) + .frame(width: max(0, min(geo.size.width, geo.size.width * CGFloat(min(1.0, audioLevel * 3)))), height: 12) + .animation(.easeOut(duration: 0.1), value: audioLevel) + } + } + .frame(height: 12) } - .buttonStyle(.borderedProminent) - } - } - Divider() + Divider() - // ================================================= - // ACTION BUTTONS (LISTEN / STOP) - // ================================================= - HStack(spacing: 12) { - Button { - startListening() - } label: { + // 2. Âm lượng thiết bị HStack { - Image(systemName: "mic.fill") - Text(isListening ? "ĐANG NGHE" : "BẮT ĐẦU NGHE") + Image(systemName: systemVolume > 0.2 ? "speaker.wave.2.fill" : "speaker.slash.fill") + .foregroundColor(systemVolume > 0.2 ? .primary : .red) + Text("Âm lượng máy nghe:") + Spacer() + Text("\(Int(systemVolume * 100))%") + .bold() + .foregroundColor(systemVolume > 0.2 ? .primary : .red) } - .font(.headline) - .frame(maxWidth: .infinity, minHeight: 48) - } - .buttonStyle(.borderedProminent) - .tint(.green) - .disabled(!isConnected || !iphoneConnected || isListening) + .font(.subheadline) - Button { - stopListening() - } label: { - HStack { - Image(systemName: "stop.fill") - Text("DỪNG") + if systemVolume < 0.15 { + Text("⚠️ Âm lượng máy đang rất nhỏ! Hãy bấm phím tăng âm lượng trên điện thoại.") + .font(.caption) + .foregroundColor(.red) } - .font(.headline) - .frame(maxWidth: .infinity, minHeight: 48) - } - .buttonStyle(.borderedProminent) - .tint(.red) - .disabled(!isListening) - } - // ================================================= - // DOWNLOAD AUDIO PACKAGE BUTTON (WAV EXPORT) - // ================================================= - Button { - if let url = NetworkManager.shared.exportRecordedWavURL() { - shareURL = url - showShareSheet = true - } - } label: { - HStack { - Image(systemName: "square.and.arrow.down.fill") - Text("Tải gói âm thanh đã nhận (\(NetworkManager.shared.getRecordedSizeString()))") - } - .font(.subheadline) - .bold() - .frame(maxWidth: .infinity, minHeight: 46) - } - .buttonStyle(.borderedProminent) - .tint(.blue) - .disabled(recordedBytes == 0) - - // ================================================= - // TEST BEEP BUTTON - // ================================================= - VStack(spacing: 6) { - Button { - testStatusMessage = NetworkManager.shared.playTestBeep() - } label: { + // 3. Cổng ra âm thanh HStack { - Image(systemName: "speaker.wave.3.fill") - Text("🔊 BẤM ĐỂ TEST LOA (PHÁT TIẾNG BÍP)") + Image(systemName: "airpodspro") + Text("Cổng xuất âm thanh:") + Spacer() + Text(outputRoute) .bold() } .font(.subheadline) - .frame(maxWidth: .infinity, minHeight: 44) - } - .buttonStyle(.bordered) - .tint(.secondary) - if !testStatusMessage.isEmpty { - Text(testStatusMessage) - .font(.caption) - .bold() - .foregroundColor(testStatusMessage.contains("✅") ? .green : .red) + // 4. Gói tin nhận + HStack { + Image(systemName: "waveform.badge.magnifyingglass") + Text("Dữ liệu nhận từ Server:") + Spacer() + Text("\(packetCount) gói (\(formatBytes(totalBytes)))") + .font(.subheadline) + .bold() + } } - } - - // ================================================= - // DIAGNOSTICS & HARDWARE MONITOR - // ================================================= - VStack(alignment: .leading, spacing: 10) { - Text("🔍 CHẨN ĐOÁN & TRẠNG THÁI MÁY NGHE") - .font(.caption) - .bold() - .foregroundColor(.secondary) - - // 1. Âm lượng thiết bị - HStack { - Image(systemName: systemVolume > 0.2 ? "speaker.wave.2.fill" : "speaker.slash.fill") - .foregroundColor(systemVolume > 0.2 ? .primary : .red) - Text("Âm lượng máy:") - Spacer() - Text("\(Int(systemVolume * 100))%") + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + + // ================================================= + // 5. DOWNLOAD AUDIO PACKAGE & TEST BEEP + // ================================================= + VStack(spacing: 10) { + Button { + if let url = NetworkManager.shared.exportRecordedWavURL() { + shareURL = url + showShareSheet = true + } + } label: { + HStack { + Image(systemName: "square.and.arrow.down.fill") + Text("Tải gói âm thanh đã nhận (\(NetworkManager.shared.getRecordedSizeString()))") + } + .font(.subheadline) .bold() - .foregroundColor(systemVolume > 0.2 ? .primary : .red) + .frame(maxWidth: .infinity, minHeight: 46) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(recordedBytes == 0) + + VStack(spacing: 4) { + Button { + testStatusMessage = NetworkManager.shared.playTestBeep() + } label: { + HStack { + Image(systemName: "speaker.wave.3.fill") + Text("🔊 BẤM ĐỂ TEST LOA (PHÁT TIẾNG BÍP)") + .bold() + } + .font(.subheadline) + .frame(maxWidth: .infinity, minHeight: 44) + } + .buttonStyle(.bordered) + .tint(.secondary) + + if !testStatusMessage.isEmpty { + Text(testStatusMessage) + .font(.caption) + .bold() + .foregroundColor(testStatusMessage.contains("✅") ? .green : .red) + } + } } - .font(.subheadline) - if systemVolume < 0.15 { - Text("⚠️ Âm lượng máy đang rất nhỏ! Hãy bấm phím tăng âm lượng trên điện thoại.") + // ================================================= + // 6. SERVER CONFIG + // ================================================= + VStack(alignment: .leading, spacing: 8) { + Text("Cấu hình Server") .font(.caption) - .foregroundColor(.red) - } + .foregroundColor(.secondary) - // 2. Cổng ra âm thanh - HStack { - Image(systemName: "airpodspro") - Text("Cổng xuất âm thanh:") - Spacer() - Text(outputRoute) - .bold() - } - .font(.subheadline) + HStack(spacing: 8) { + TextField("IP Server", text: $serverIP) + .textFieldStyle(.roundedBorder) + .keyboardType(.numbersAndPunctuation) + .autocorrectionDisabled() - // 3. Gói tin nhận - HStack { - Image(systemName: "waveform.badge.magnifyingglass") - Text("Dữ liệu nhận từ Server:") - Spacer() - Text("\(packetCount) gói (\(formatBytes(totalBytes)))") - .font(.subheadline) - .bold() + TextField("Port", text: $serverPort) + .textFieldStyle(.roundedBorder) + .keyboardType(.numberPad) + .frame(width: 75) + } + + HStack(spacing: 8) { + TextField("Auth Token", text: $authToken) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + Button("LƯU") { + saveServer() + } + .buttonStyle(.borderedProminent) + } } - // 4. VU Meter (Thanh đo mức âm thanh thời gian thực) - VStack(alignment: .leading, spacing: 4) { + // ================================================= + // 7. LIVE DEBUG LOG CONSOLE (TERMINAL TRÊN APP) + // ================================================= + VStack(alignment: .leading, spacing: 6) { HStack { - Text("Tín hiệu Mic đang thu (VU Meter):") + Text("📋 LIVE DEBUG LOG") .font(.caption) + .bold() .foregroundColor(.secondary) Spacer() - Text(String(format: "%.3f", audioLevel)) - .font(.caption) - .monospacedDigit() + Button("XÓA LOG") { + AppLogger.shared.clear() + } + .font(.caption2) } - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(Color(.tertiarySystemFill)) - .frame(height: 12) - - RoundedRectangle(cornerRadius: 4) - .fill(audioLevelColor) - .frame(width: max(0, min(geo.size.width, geo.size.width * CGFloat(min(1.0, audioLevel * 3)))), height: 12) - .animation(.easeOut(duration: 0.1), value: audioLevel) + ScrollView { + VStack(alignment: .leading, spacing: 3) { + ForEach(logger.logs.indices, id: \.self) { idx in + Text(logger.logs[idx]) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.green) + .frame(maxWidth: .infinity, alignment: .leading) + } } + .padding(6) } - .frame(height: 12) + .frame(height: 120) + .background(Color.black.opacity(0.9)) + .cornerRadius(8) } + .padding() + .background(Color(.secondarySystemBackground)) + .cornerRadius(12) + + // Khoảng trống đệm để tránh bị che bởi thanh nút bấm ghim ở dưới + Spacer(minLength: 85) } - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(12) + .padding(.horizontal) + } - // ================================================= - // LIVE DEBUG LOG CONSOLE (TERMINAL TRÊN APP) - // ================================================= - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("📋 LIVE DEBUG LOG") - .font(.caption) - .bold() - .foregroundColor(.secondary) - Spacer() - Button("XÓA LOG") { - AppLogger.shared.clear() + // ===================================================== + // 8. PINNED BOTTOM ACTION BAR (BẮT ĐẦU NGHE / DỪNG) + // ===================================================== + VStack(spacing: 0) { + Divider() + + HStack(spacing: 12) { + Button { + startListening() + } label: { + HStack { + Image(systemName: "mic.fill") + Text(isListening ? "ĐANG NGHE" : "BẮT ĐẦU NGHE") } - .font(.caption2) + .font(.headline) + .frame(maxWidth: .infinity, minHeight: 48) } + .buttonStyle(.borderedProminent) + .tint(.green) + .disabled(!isConnected || !iphoneConnected || isListening) - ScrollView { - VStack(alignment: .leading, spacing: 3) { - ForEach(logger.logs.indices, id: \.self) { idx in - Text(logger.logs[idx]) - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(.green) - .frame(maxWidth: .infinity, alignment: .leading) - } + Button { + stopListening() + } label: { + HStack { + Image(systemName: "stop.fill") + Text("DỪNG") } - .padding(6) + .font(.headline) + .frame(maxWidth: .infinity, minHeight: 48) } - .frame(height: 130) - .background(Color.black.opacity(0.9)) - .cornerRadius(8) + .buttonStyle(.borderedProminent) + .tint(.red) + .disabled(!isListening) } - .padding() - .background(Color(.secondarySystemBackground)) - .cornerRadius(12) - - Spacer(minLength: 20) + .padding(.horizontal) + .padding(.top, 10) + .padding(.bottom, 12) } - .padding(.horizontal) + .background(.ultraThinMaterial) } .sheet(isPresented: $showShareSheet) { if let url = shareURL { @@ -531,3 +541,4 @@ struct ContentView: View { } } + diff --git a/server.js b/server.js index d640546..fbab544 100644 --- a/server.js +++ b/server.js @@ -1048,19 +1048,6 @@ app.get( background: #d33; - color: - white; - } - - audio { - - width: - 100%; - - margin-top: - 25px; - } - .info { margin-top: @@ -1119,6 +1106,17 @@ app.get(
+ +
+
+ 📶 Tín hiệu Mic (VU Meter): + 0.000 +
+
+
+
+
+
- -
MIC REMOTE SERVER v1.1.0
+