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:
cdnutterandClaude Sonnet 5.5 committed 2026-10-20 14:11:58 -07:00
1 parent a0fe35a8d8
commit 926ad6ee99
10 files changed
+350 -10

No files matched your search

+98
View File
@@ -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: ", ")
}
}
+2 -1
View File
@@ -102,7 +102,7 @@ final class Settings: @unchecked Sendable {
private static let keys = [
"plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
"stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume",
"stemQuality", "cacheLimitGb", "downloadsWifiOnly", "downloadSort", "keepStemMix", "masterVolume", "tabLayout",
]
private func read<T>(_ kp: KeyPath<Settings, T>, _ key: String, _ def: T) -> T {
@@ -175,6 +175,7 @@ final class Settings: @unchecked Sendable {
/// Plex path prefixes that matched the stems index, newline-separated.
var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } }
var tabLayout: String { get { read(\.tabLayout, "tabLayout", Tab.encode(Tab.defaultLayout)) } set { write(\.tabLayout, "tabLayout", newValue, Tab.encode(Tab.defaultLayout)) } }
var theme: ThemeMode { get { readEnum(\.theme, "theme", .Black) } set { writeEnum(\.theme, "theme", newValue, .Black) } }
var accent: Accent { get { readEnum(\.accent, "accent", .Music) } set { writeEnum(\.accent, "accent", newValue, .Music) } }
var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } }