ios: reorderable tab bar with more tabs, debug log export, AirPlay picker, more room above seeker
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a0fe35a8d8
commit
926ad6ee99
10 files changed
+350
-10
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// App log kept in a size-capped file so a tester can send it. Tokens are stripped before anything is written.
|
||||
final class Log: @unchecked Sendable {
|
||||
static let shared = Log()
|
||||
enum Level: String { case debug = "D", info = "I", error = "E" }
|
||||
|
||||
static let verboseKey = "verboseLogging"
|
||||
static var verbose: Bool {
|
||||
get { UserDefaults.standard.bool(forKey: verboseKey) }
|
||||
set { UserDefaults.standard.set(newValue, forKey: verboseKey) }
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private let url: URL
|
||||
private let maxBytes = 512 * 1024
|
||||
private let stamp: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "MM-dd HH:mm:ss.SSS"
|
||||
return f
|
||||
}()
|
||||
|
||||
private init() {
|
||||
let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||
url = dir.appendingPathComponent("stemify.log")
|
||||
}
|
||||
|
||||
private static let secret = try! NSRegularExpression(
|
||||
pattern: #"(X-Plex-Token|X-Stem-Token|token|Authorization)([=:"' ]+)[^&\s"',}]+"#, options: [.caseInsensitive])
|
||||
|
||||
static func redact(_ s: String) -> String {
|
||||
secret.stringByReplacingMatches(in: s, range: NSRange(s.startIndex..., in: s), withTemplate: "$1$2<redacted>")
|
||||
}
|
||||
|
||||
func write(_ level: Level, _ tag: String, _ msg: String) {
|
||||
if level == .debug && !Log.verbose { return }
|
||||
let line = "\(stamp.string(from: Date())) \(level.rawValue) [\(tag)] \(Log.redact(msg))\n"
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
guard let data = line.data(using: .utf8) else { return }
|
||||
let size = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0
|
||||
if size > maxBytes { trim() }
|
||||
if FileManager.default.fileExists(atPath: url.path) { append(data) } else { try? data.write(to: url) }
|
||||
}
|
||||
|
||||
/// Keeps the newest half of the file.
|
||||
private func trim() {
|
||||
guard let all = try? Data(contentsOf: url) else { return }
|
||||
let tail = all.suffix(maxBytes / 2)
|
||||
let cut = tail.firstIndex(of: 0x0A).map { tail.index(after: $0) } ?? tail.startIndex
|
||||
try? Data(tail[cut...]).write(to: url)
|
||||
}
|
||||
|
||||
private func append(_ data: Data) {
|
||||
guard let h = try? FileHandle(forWritingTo: url) else { return }
|
||||
defer { try? h.close() }
|
||||
_ = try? h.seekToEnd()
|
||||
try? h.write(contentsOf: data)
|
||||
}
|
||||
|
||||
func text() -> String {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return (try? String(contentsOf: url, encoding: .utf8)) ?? ""
|
||||
}
|
||||
|
||||
func clear() {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
/// Device and app details followed by the log, written to a temp file for the share sheet.
|
||||
func export() -> URL {
|
||||
let b = Bundle.main
|
||||
let d = UIDevice.current
|
||||
var head = "Stemify \(b.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?") "
|
||||
head += "(\(b.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"))\n"
|
||||
head += "\(d.model) · \(d.systemName) \(d.systemVersion) · \(UIScreen.main.maximumFramesPerSecond) Hz\n"
|
||||
head += "Verbose logging: \(Log.verbose ? "on" : "off")\n"
|
||||
head += "Output: \(AirRoute.describe())\n\n"
|
||||
let out = FileManager.default.temporaryDirectory.appendingPathComponent("stemify-log.txt")
|
||||
try? (head + text()).write(to: out, atomically: true, encoding: .utf8)
|
||||
return out
|
||||
}
|
||||
|
||||
func debug(_ tag: String, _ msg: @autoclosure () -> String) { if Log.verbose { write(.debug, tag, msg()) } }
|
||||
func info(_ tag: String, _ msg: String) { write(.info, tag, msg) }
|
||||
func error(_ tag: String, _ msg: String) { write(.error, tag, msg) }
|
||||
}
|
||||
|
||||
/// Where audio is currently going (speaker, AirPlay, Bluetooth…).
|
||||
enum AirRoute {
|
||||
static func describe() -> String {
|
||||
let outs = AVAudioSession.sharedInstance().currentRoute.outputs.map { "\($0.portName) (\($0.portType.rawValue))" }
|
||||
return outs.isEmpty ? "none" : outs.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user