Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
926ad6ee99 | ||
|
|
a0fe35a8d8 | ||
|
|
d773b3e5a5 | ||
|
|
9c1e70da4d | ||
|
|
4e2816673d | ||
|
|
9646b4c010 |
No files matched your search
@@ -0,0 +1,84 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// What an export file holds: stem playlists (with each song's mix) and the non-secret sound settings.
|
||||||
|
/// Plex and stem server tokens and URLs are never included.
|
||||||
|
struct StemifyExport: Codable {
|
||||||
|
static let format = "stemify-export"
|
||||||
|
static let version = 1
|
||||||
|
|
||||||
|
struct Prefs: Codable {
|
||||||
|
var stemQuality: String?
|
||||||
|
var keepStemMix: Bool?
|
||||||
|
var karaokeVocals: Int?
|
||||||
|
var karaokeSoftDb: Int?
|
||||||
|
var playbackSpeed: Double?
|
||||||
|
var downloadsWifiOnly: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
var format = StemifyExport.format
|
||||||
|
var version = StemifyExport.version
|
||||||
|
var exported = Date()
|
||||||
|
var playlists: [StemPlaylist]
|
||||||
|
var prefs: Prefs
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func capture(_ lists: StemPlaylists, _ s: Settings) -> StemifyExport {
|
||||||
|
StemifyExport(playlists: lists.lists, prefs: Prefs(
|
||||||
|
stemQuality: s.stemQuality.rawValue, keepStemMix: s.keepStemMix, karaokeVocals: s.karaokeVocals,
|
||||||
|
karaokeSoftDb: s.karaokeSoftDb, playbackSpeed: s.playbackSpeed, downloadsWifiOnly: s.downloadsWifiOnly))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encoded() throws -> Data {
|
||||||
|
let e = JSONEncoder()
|
||||||
|
e.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
e.dateEncodingStrategy = .iso8601
|
||||||
|
return try e.encode(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ data: Data) throws -> StemifyExport {
|
||||||
|
let d = JSONDecoder()
|
||||||
|
d.dateDecodingStrategy = .iso8601
|
||||||
|
let x = try d.decode(StemifyExport.self, from: data)
|
||||||
|
guard x.format == Self.format else { throw BackupError.notAnExport }
|
||||||
|
guard x.version <= Self.version else { throw BackupError.newerVersion }
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func apply(to s: Settings) {
|
||||||
|
if let v = prefs.stemQuality.flatMap(StemQuality.init(rawValue:)) { s.stemQuality = v }
|
||||||
|
if let v = prefs.keepStemMix { s.keepStemMix = v }
|
||||||
|
if let v = prefs.karaokeVocals { s.karaokeVocals = v }
|
||||||
|
if let v = prefs.karaokeSoftDb { s.karaokeSoftDb = v }
|
||||||
|
if let v = prefs.playbackSpeed { s.playbackSpeed = v }
|
||||||
|
if let v = prefs.downloadsWifiOnly { s.downloadsWifiOnly = v }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BackupError: LocalizedError {
|
||||||
|
case notAnExport, newerVersion
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notAnExport: "That file isn't a Stemify export."
|
||||||
|
case .newerVersion: "That export is from a newer version of Stemify."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where an export goes and comes from. The Files sheet covers local storage and iCloud Drive;
|
||||||
|
/// a direct iCloud (ubiquity container / CloudKit) backend would be another conformer.
|
||||||
|
protocol BackupDestination {
|
||||||
|
func write(_ data: Data, name: String) async throws
|
||||||
|
func read() async throws -> Data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The export as a document for fileExporter, which also reaches iCloud Drive through Files.
|
||||||
|
struct ExportDocument: FileDocument {
|
||||||
|
static var readableContentTypes: [UTType] { [.json] }
|
||||||
|
var data: Data
|
||||||
|
init(data: Data = Data()) { self.data = data }
|
||||||
|
init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
|
||||||
|
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) }
|
||||||
|
}
|
||||||
@@ -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: ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ final class Settings: @unchecked Sendable {
|
|||||||
private static let keys = [
|
private static let keys = [
|
||||||
"plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
|
"plexUrl", "plexToken", "plexUser", "plexMachineId", "musicSection", "musicSectionTitle",
|
||||||
"stemUrl", "stemToken", "stemRoots", "theme", "accent", "albumColorNowPlaying", "highRefreshRate",
|
"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 {
|
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.
|
/// Plex path prefixes that matched the stems index, newline-separated.
|
||||||
var stemRoots: String { get { read(\.stemRoots, "stemRoots", "") } set { write(\.stemRoots, "stemRoots", newValue, "") } }
|
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 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 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) } }
|
var albumColorNowPlaying: Bool { get { read(\.albumColorNowPlaying, "albumColorNowPlaying", false) } set { write(\.albumColorNowPlaying, "albumColorNowPlaying", newValue, false) } }
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ final class StemDecoder: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if !released {
|
if !released {
|
||||||
NSLog("StemDecoder %@ failed: %@", name, error.localizedDescription)
|
Log.shared.error("decoder", "\(name) failed: \(error.localizedDescription)")
|
||||||
state.with { _error = error }
|
state.with { _error = error }
|
||||||
}
|
}
|
||||||
ring.eos = true
|
ring.eos = true
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
|
|
||||||
/// Surfaces a message wherever messages show (toasts on the phone, an alert in the car).
|
/// Surfaces a message wherever messages show (toasts on the phone, an alert in the car).
|
||||||
func report(_ text: String) {
|
func report(_ text: String) {
|
||||||
|
Log.shared.error("report", text)
|
||||||
message = AppMessage(text: text)
|
message = AppMessage(text: text)
|
||||||
messageListeners.values.forEach { $0(text) }
|
messageListeners.values.forEach { $0(text) }
|
||||||
}
|
}
|
||||||
@@ -797,6 +798,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
guard let self, let info = n.userInfo,
|
guard let self, let info = n.userInfo,
|
||||||
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||||
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
|
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
|
||||||
|
Log.shared.info("session", "interruption \(type == .began ? "began" : "ended")")
|
||||||
switch type {
|
switch type {
|
||||||
case .began:
|
case .began:
|
||||||
// A stale "began" arrives after the app was suspended; the session wasn't really taken.
|
// A stale "began" arrives after the app was suspended; the session wasn't really taken.
|
||||||
@@ -813,12 +815,14 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
|||||||
nc.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] n in
|
nc.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) { [weak self] n in
|
||||||
guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||||
let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }
|
let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }
|
||||||
|
Log.shared.info("route", "change reason=\(reason.rawValue) now \(AirRoute.describe())")
|
||||||
// Headphones unplugged or Bluetooth gone: don't carry on out of the speaker.
|
// Headphones unplugged or Bluetooth gone: don't carry on out of the speaker.
|
||||||
if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() }
|
if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() }
|
||||||
self.eq.refreshRoute()
|
self.eq.refreshRoute()
|
||||||
}
|
}
|
||||||
nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
|
nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
Log.shared.error("session", "media services reset")
|
||||||
let was = self.playWhenReady
|
let was = self.playWhenReady
|
||||||
let pos = self.positionMs()
|
let pos = self.positionMs()
|
||||||
self.currentToken = -1
|
self.currentToken = -1
|
||||||
|
|||||||
@@ -70,6 +70,24 @@ final class StemPlaylists {
|
|||||||
return MixPresets.current(allStems, mix)?.label ?? "Custom Mix"
|
return MixPresets.current(allStems, mix)?.label ?? "Custom Mix"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Brings in playlists from an export: new ids are added, ids already here are replaced when the
|
||||||
|
/// import was updated more recently. Returns how many lists were added or replaced.
|
||||||
|
@discardableResult
|
||||||
|
func merge(_ incoming: [StemPlaylist]) -> Int {
|
||||||
|
var n = 0
|
||||||
|
for pl in incoming {
|
||||||
|
if let i = lists.firstIndex(where: { $0.id == pl.id }) {
|
||||||
|
guard pl.updated > lists[i].updated else { continue }
|
||||||
|
lists[i] = pl
|
||||||
|
} else {
|
||||||
|
lists.append(pl)
|
||||||
|
}
|
||||||
|
n += 1
|
||||||
|
}
|
||||||
|
if n > 0 { save() }
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
|
private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) {
|
||||||
guard let i = lists.firstIndex(where: { $0.id == id }) else { return }
|
guard let i = lists.firstIndex(where: { $0.id == id }) else { return }
|
||||||
change(&lists[i])
|
change(&lists[i])
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import AVKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
let MARGIN: CGFloat = 20
|
let MARGIN: CGFloat = 20
|
||||||
|
|
||||||
@@ -330,3 +332,21 @@ private struct ListRow: ViewModifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The system AirPlay / Bluetooth output picker. The mix is rendered on the phone, so stem edits keep working over AirPlay.
|
||||||
|
struct AirPlayButton: UIViewRepresentable {
|
||||||
|
var tint: UIColor = .label
|
||||||
|
var active: UIColor = .systemBlue
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> AVRoutePickerView {
|
||||||
|
let v = AVRoutePickerView()
|
||||||
|
v.prioritizesVideoDevices = false
|
||||||
|
v.backgroundColor = .clear
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ v: AVRoutePickerView, context: Context) {
|
||||||
|
v.tintColor = tint
|
||||||
|
v.activeTintColor = active
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,32 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum Tab: String, CaseIterable, Hashable {
|
enum Tab: String, CaseIterable, Hashable {
|
||||||
case Library, Playlists, Search, Downloads, Settings
|
case Library, Songs, Albums, Artists, Genres, Playlists, Search, Downloads, Settings
|
||||||
|
|
||||||
|
static let defaultLayout: [Tab] = [.Library, .Playlists, .Search, .Downloads, .Settings]
|
||||||
|
/// iOS folds anything past five tabs into "More", so the bar holds at most this many.
|
||||||
|
static let maxVisible = 5
|
||||||
|
|
||||||
|
/// Parses the saved comma list; Settings is always kept so the app can't lock itself out.
|
||||||
|
static func layout(_ raw: String) -> [Tab] {
|
||||||
|
var out: [Tab] = []
|
||||||
|
for part in raw.split(separator: ",") {
|
||||||
|
if let t = Tab(rawValue: String(part)), !out.contains(t) { out.append(t) }
|
||||||
|
}
|
||||||
|
if out.isEmpty { return defaultLayout }
|
||||||
|
if !out.contains(.Settings) { out = Array(out.prefix(maxVisible - 1)) + [.Settings] }
|
||||||
|
return Array(out.prefix(maxVisible))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func encode(_ tabs: [Tab]) -> String { tabs.map(\.rawValue).joined(separator: ",") }
|
||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .Library: "square.stack.fill"
|
case .Library: "square.stack.fill"
|
||||||
|
case .Songs: "music.note"
|
||||||
|
case .Albums: "square.stack"
|
||||||
|
case .Artists: "music.mic"
|
||||||
|
case .Genres: "guitars.fill"
|
||||||
case .Playlists: "music.note.list"
|
case .Playlists: "music.note.list"
|
||||||
case .Search: "magnifyingglass"
|
case .Search: "magnifyingglass"
|
||||||
case .Downloads: "arrow.down.circle.fill"
|
case .Downloads: "arrow.down.circle.fill"
|
||||||
@@ -15,10 +36,10 @@ enum Tab: String, CaseIterable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum Route: Hashable {
|
enum Route: Hashable {
|
||||||
case Artists, Albums, Songs, Playlists
|
case Artists, Albums, Songs, Playlists, Genres, Downloads, TabBar
|
||||||
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
||||||
case PlexAccount, StemServer, Quality, Storage, Appearance
|
case PlexAccount, StemServer, Quality, Storage, Appearance
|
||||||
case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
|
case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
|
/// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell.
|
||||||
@@ -47,13 +68,30 @@ final class Ui {
|
|||||||
if t == tab { paths[t] = [] } else { tab = t }
|
if t == tab { paths[t] = [] } else { tab = t }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tabs currently in the bar, in order.
|
||||||
|
var tabs: [Tab] { Tab.layout(AppGraph.shared.settings.tabLayout) }
|
||||||
|
|
||||||
|
/// Where library pages open: Library if it's in the bar, else the first tab that is.
|
||||||
|
private var home: Tab { tabs.contains(.Library) ? .Library : (tabs.first ?? .Settings) }
|
||||||
|
|
||||||
|
/// Downloads as its own tab, or pushed onto the current one when it's hidden.
|
||||||
|
func openDownloads() {
|
||||||
|
if tabs.contains(.Downloads) { selectTab(.Downloads) } else { push(.Downloads) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Falls back to the first tab when the selected one was just removed from the bar.
|
||||||
|
func ensureValidTab() {
|
||||||
|
if !tabs.contains(tab) { tab = tabs.first ?? .Settings }
|
||||||
|
}
|
||||||
|
|
||||||
func openNowPlaying() { nowPlayingOpen = true }
|
func openNowPlaying() { nowPlayingOpen = true }
|
||||||
|
|
||||||
/// Switches to the Library tab and pushes [r] there.
|
/// Switches to the Library tab and pushes [r] there.
|
||||||
func showInLibrary(_ r: Route) {
|
func showInLibrary(_ r: Route) {
|
||||||
nowPlayingOpen = false
|
nowPlayingOpen = false
|
||||||
tab = .Library
|
let t = home
|
||||||
paths[.Library, default: []].append(r)
|
tab = t
|
||||||
|
paths[t, default: []].append(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switches to Settings and opens [r] (the Equalizer) there.
|
/// Switches to Settings and opens [r] (the Equalizer) there.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ struct RootView: View {
|
|||||||
let s = g.settings
|
let s = g.settings
|
||||||
let p = Palette.of(s.theme, s.accent)
|
let p = Palette.of(s.theme, s.accent)
|
||||||
TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) {
|
TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) {
|
||||||
ForEach(Tab.allCases, id: \.self) { t in
|
ForEach(Tab.layout(s.tabLayout), id: \.self) { t in
|
||||||
NavigationStack(path: ui.path(t)) {
|
NavigationStack(path: ui.path(t)) {
|
||||||
root(t).miniPlayerRoom()
|
root(t).miniPlayerRoom()
|
||||||
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
|
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
|
||||||
@@ -20,6 +20,7 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.modifier(GlassMiniPlayerHost())
|
.modifier(GlassMiniPlayerHost())
|
||||||
|
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
|
||||||
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
||||||
NowPlayingScreen()
|
NowPlayingScreen()
|
||||||
.overlayHost(ui.overlays, level: 1)
|
.overlayHost(ui.overlays, level: 1)
|
||||||
@@ -41,6 +42,10 @@ struct RootView: View {
|
|||||||
@ViewBuilder private func root(_ t: Tab) -> some View {
|
@ViewBuilder private func root(_ t: Tab) -> some View {
|
||||||
switch t {
|
switch t {
|
||||||
case .Library: LibraryHome()
|
case .Library: LibraryHome()
|
||||||
|
case .Songs: SongsScreen()
|
||||||
|
case .Albums: AlbumsScreen()
|
||||||
|
case .Artists: ArtistsScreen()
|
||||||
|
case .Genres: GenresScreen()
|
||||||
case .Playlists: PlaylistsScreen()
|
case .Playlists: PlaylistsScreen()
|
||||||
case .Search: SearchScreen()
|
case .Search: SearchScreen()
|
||||||
case .Downloads: DownloadsScreen()
|
case .Downloads: DownloadsScreen()
|
||||||
@@ -54,6 +59,10 @@ struct RootView: View {
|
|||||||
case .Albums: AlbumsScreen()
|
case .Albums: AlbumsScreen()
|
||||||
case .Songs: SongsScreen()
|
case .Songs: SongsScreen()
|
||||||
case .Playlists: PlaylistsScreen()
|
case .Playlists: PlaylistsScreen()
|
||||||
|
case .Genres: GenresScreen()
|
||||||
|
case .Downloads: DownloadsScreen()
|
||||||
|
case .TabBar: TabBarScreen()
|
||||||
|
case .GenrePage(let g): GenreScreen(genre: g)
|
||||||
case .Eq: EqScreen()
|
case .Eq: EqScreen()
|
||||||
case .EqFilters(let id): EqFiltersScreen(profileId: id)
|
case .EqFilters(let id): EqFiltersScreen(profileId: id)
|
||||||
case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f)
|
case .EqFilterPage(let id, let f): EqFilterPage(profileId: id, filterId: f)
|
||||||
|
|||||||
@@ -642,3 +642,56 @@ struct DownloadsScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- genres -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct GenresScreen: View {
|
||||||
|
@Environment(Ui.self) private var ui
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
@State private var albums: LoadState<[Album]> = .loading
|
||||||
|
@State private var reload = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Page(title: "Genres") {
|
||||||
|
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||||
|
let counts = Dictionary(grouping: list.compactMap(\.genre).filter { !$0.isEmpty }, by: { $0 }).mapValues(\.count)
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(counts.keys.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, id: \.self) { g in
|
||||||
|
Button { ui.push(.GenrePage(g)) } label: {
|
||||||
|
HStack {
|
||||||
|
Text(g).foregroundStyle(p.label)
|
||||||
|
Spacer()
|
||||||
|
Text("\(counts[g] ?? 0)").foregroundStyle(p.label2)
|
||||||
|
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label2)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
Divider().padding(.leading, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||||
|
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GenreScreen: View {
|
||||||
|
@Environment(Ui.self) private var ui
|
||||||
|
let genre: String
|
||||||
|
@State private var albums: LoadState<[Album]> = .loading
|
||||||
|
@State private var reload = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Page(title: genre) {
|
||||||
|
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||||
|
AlbumGrid(albums: list.filter { $0.genre == genre }, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||||
|
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,21 +98,32 @@ struct NowPlayingScreen: View {
|
|||||||
let pm = g.playback
|
let pm = g.playback
|
||||||
let c = tint.map { base.tinted($0) } ?? base
|
let c = tint.map { base.tinted($0) } ?? base
|
||||||
let track = pm.current?.track
|
let track = pm.current?.track
|
||||||
VStack(spacing: 0) {
|
// Wide when there's no room to stack art over controls: landscape, or iPhone Duo's
|
||||||
|
// outer display. Tall layouts (including the unfolded Duo) cap the controls' width.
|
||||||
|
GeometryReader { geo in
|
||||||
|
let wide = geo.size.width > geo.size.height * 0.9
|
||||||
if let track {
|
if let track {
|
||||||
Group {
|
if wide {
|
||||||
switch panel {
|
HStack(spacing: 0) {
|
||||||
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady)
|
panelView(track, header: false)
|
||||||
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() }
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() }
|
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
||||||
case .Lyrics: VStack(spacing: 0) { MiniHeader(track: track); LyricsPanel(track: track) }
|
controls(track, wide: true)
|
||||||
}
|
.frame(width: min(geo.size.width * 0.48, 440))
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
panelView(track, header: true)
|
||||||
.frame(maxHeight: .infinity)
|
.frame(maxHeight: .infinity)
|
||||||
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
||||||
controls(track)
|
controls(track, wide: false).frame(maxWidth: 560)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2).frame(maxHeight: .infinity)
|
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
@@ -131,11 +142,21 @@ struct NowPlayingScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func controls(_ track: Track) -> some View {
|
@ViewBuilder
|
||||||
|
private func panelView(_ track: Track, header: Bool) -> some View {
|
||||||
|
switch panel {
|
||||||
|
case .Art: ArtPanel(thumb: track.thumb, playing: g.playback.playWhenReady)
|
||||||
|
case .Mixer: VStack(spacing: 0) { if header { MiniHeader(track: track) }; MixerPanel() }
|
||||||
|
case .Queue: VStack(spacing: 0) { if header { MiniHeader(track: track) }; QueuePanel() }
|
||||||
|
case .Lyrics: VStack(spacing: 0) { if header { MiniHeader(track: track) }; LyricsPanel(track: track) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func controls(_ track: Track, wide: Bool) -> some View {
|
||||||
let pm = g.playback
|
let pm = g.playback
|
||||||
let c = tint.map { base.tinted($0) } ?? base
|
let c = tint.map { base.tinted($0) } ?? base
|
||||||
return VStack(spacing: 0) {
|
return VStack(spacing: 0) {
|
||||||
if panel == .Art {
|
if wide || panel == .Art {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
Text(track.title).font(.title3.weight(.semibold)).foregroundStyle(c.label).lineLimit(1)
|
Text(track.title).font(.title3.weight(.semibold)).foregroundStyle(c.label).lineLimit(1)
|
||||||
@@ -151,7 +172,7 @@ struct NowPlayingScreen: View {
|
|||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
}
|
}
|
||||||
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
|
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
|
||||||
.padding(.top, panel == .Art ? 34 : 18)
|
.padding(.top, wide ? 24 : panel == .Art ? 34 : 30)
|
||||||
HStack {
|
HStack {
|
||||||
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -178,6 +199,7 @@ struct NowPlayingScreen: View {
|
|||||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
||||||
SpeedMenu()
|
SpeedMenu()
|
||||||
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
||||||
|
AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40)
|
||||||
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
|
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
|
||||||
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
||||||
}
|
}
|
||||||
@@ -604,18 +626,11 @@ private struct MixerPanel: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
p.separator.frame(height: 0.5).padding(.vertical, 10)
|
p.separator.frame(height: 0.5).padding(.vertical, 6)
|
||||||
HStack {
|
FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false) {
|
||||||
Text("Master").font(.headline).foregroundStyle(p.label)
|
|
||||||
Spacer()
|
|
||||||
Text("\(Int(pm.master * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2)
|
|
||||||
}
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Image(systemName: "speaker.fill").font(.system(size: 13)).foregroundStyle(p.label2)
|
|
||||||
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
|
FaderSlider(value: pm.master, fill: p.label, onChange: pm.setMaster)
|
||||||
Image(systemName: "speaker.wave.3.fill").font(.system(size: 14)).foregroundStyle(p.label2)
|
} trailing: { EmptyView() }
|
||||||
}
|
Spacer(minLength: 12)
|
||||||
Spacer(minLength: 16)
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
}
|
}
|
||||||
@@ -655,17 +670,8 @@ private struct StemFader: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let color = Color(hex: Stems.color(name))
|
let color = Color(hex: Stems.color(name))
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted) {
|
||||||
HStack(spacing: 8) {
|
VStack(spacing: 0) {
|
||||||
Circle().fill(setting.muted ? p.label3 : color).frame(width: 10, height: 10)
|
|
||||||
Text(Stems.label(name)).font(.headline).foregroundStyle(setting.muted ? p.label2 : p.label)
|
|
||||||
Spacer()
|
|
||||||
Text("\(Int(stemTaper(setting.level) * 100))%").font(.footnote.monospacedDigit()).foregroundStyle(p.label2).padding(.trailing, 4)
|
|
||||||
if enabled {
|
|
||||||
ToggleBox(text: "M", on: setting.muted, onColor: p.red) { pm.toggleMute(name) }
|
|
||||||
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00)) { pm.toggleSolo(name) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
|
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
|
||||||
pm.setStemLevel(name, $0)
|
pm.setStemLevel(name, $0)
|
||||||
}
|
}
|
||||||
@@ -680,7 +686,39 @@ private struct StemFader: View {
|
|||||||
.frame(height: 3)
|
.frame(height: 3)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
}
|
}
|
||||||
.padding(.top, 10)
|
} trailing: {
|
||||||
|
if enabled {
|
||||||
|
ToggleBox(text: "M", on: setting.muted, onColor: p.red) { pm.toggleMute(name) }
|
||||||
|
ToggleBox(text: "S", on: setting.solo, onColor: Color(hex: 0xFFCC00)) { pm.toggleSolo(name) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One mixer line: name, fader, level and buttons side by side, so all six stems and the
|
||||||
|
/// master fit without scrolling (the faders grab drags, so the list can't scroll well).
|
||||||
|
private struct FaderRow<Fader: View, Trailing: View>: View {
|
||||||
|
let dot: Color?
|
||||||
|
let label: String
|
||||||
|
let percent: Int
|
||||||
|
let dim: Bool
|
||||||
|
@ViewBuilder let fader: Fader
|
||||||
|
@ViewBuilder let trailing: Trailing
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if let dot { Circle().fill(dot).frame(width: 8, height: 8) }
|
||||||
|
Text(label).font(.subheadline.weight(.semibold)).foregroundStyle(dim ? p.label2 : p.label)
|
||||||
|
.lineLimit(1).minimumScaleFactor(0.8)
|
||||||
|
}
|
||||||
|
.frame(width: 70, alignment: .leading)
|
||||||
|
fader
|
||||||
|
Text("\(percent)%").font(.caption.monospacedDigit()).foregroundStyle(p.label2)
|
||||||
|
.frame(width: 36, alignment: .trailing)
|
||||||
|
trailing
|
||||||
|
}
|
||||||
|
.frame(height: 46)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
/// A Settings.app row that pushes [route]: icon, title, grey value, chevron.
|
/// A Settings.app row that pushes [route]: icon, title, grey value, chevron.
|
||||||
private struct NavCell: View {
|
private struct NavCell: View {
|
||||||
@@ -147,6 +148,16 @@ struct SettingsScreen: View {
|
|||||||
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
|
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
|
||||||
ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
|
ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
|
||||||
} header: { Header(text: "Storage") }
|
} header: { Header(text: "Storage") }
|
||||||
|
Section {
|
||||||
|
BackupCells()
|
||||||
|
} header: { Header(text: "Backup") } footer: {
|
||||||
|
Footer(text: "Stem playlists, their mixes and sound settings. Tokens and server addresses are left out. Save to iCloud Drive from the Files sheet.")
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
DebugCells()
|
||||||
|
} header: { Header(text: "Debug") } footer: {
|
||||||
|
Footer(text: "Verbose adds playback and network detail. Tokens are removed from the log.")
|
||||||
|
}
|
||||||
Section {
|
Section {
|
||||||
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
|
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
|
||||||
} header: { Header(text: "Display") } footer: {
|
} header: { Header(text: "Display") } footer: {
|
||||||
@@ -159,6 +170,42 @@ struct SettingsScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct BackupCells: View {
|
||||||
|
private var g: AppGraph { .shared }
|
||||||
|
@State private var doc = ExportDocument()
|
||||||
|
@State private var exporting = false
|
||||||
|
@State private var importing = false
|
||||||
|
@State private var message: String?
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
do { doc = ExportDocument(data: try StemifyExport.capture(g.stemPlaylists, g.settings).encoded()); exporting = true }
|
||||||
|
catch { message = error.localizedDescription }
|
||||||
|
} label: { ValueCell(title: "Export", value: "\(g.stemPlaylists.lists.count) playlists", icon: "square.and.arrow.up", iconBg: 0x007AFF) }
|
||||||
|
.themedRow()
|
||||||
|
Button { importing = true } label: { ValueCell(title: "Import", value: nil, icon: "square.and.arrow.down", iconBg: 0x34C759) }
|
||||||
|
.themedRow()
|
||||||
|
.fileExporter(isPresented: $exporting, document: doc, contentType: .json, defaultFilename: "Stemify \(Date().formatted(.iso8601.year().month().day()))") { r in
|
||||||
|
if case .failure(let e) = r { message = e.localizedDescription }
|
||||||
|
}
|
||||||
|
.fileImporter(isPresented: $importing, allowedContentTypes: [.json]) { r in
|
||||||
|
do {
|
||||||
|
let url = try r.get()
|
||||||
|
let scoped = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
let x = try StemifyExport.decode(Data(contentsOf: url))
|
||||||
|
let n = g.stemPlaylists.merge(x.playlists)
|
||||||
|
x.apply(to: g.settings)
|
||||||
|
message = "Imported \(n) playlist\(n == 1 ? "" : "s") and your sound settings."
|
||||||
|
} catch { message = error.localizedDescription }
|
||||||
|
}
|
||||||
|
.alert("Backup", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: { Text(message ?? "") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- plex -----------------------------------------------------------------------------------
|
// ---- plex -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
struct PlexAccountScreen: View {
|
struct PlexAccountScreen: View {
|
||||||
@@ -466,7 +513,7 @@ struct StorageScreen: View {
|
|||||||
let usage = g.cache.usage
|
let usage = g.cache.usage
|
||||||
List {
|
List {
|
||||||
Section {
|
Section {
|
||||||
Button { ui.selectTab(.Downloads) } label: {
|
Button { ui.openDownloads() } label: {
|
||||||
HStack {
|
HStack {
|
||||||
ValueCell(title: "Downloads", value: gb(usage.pinnedBytes))
|
ValueCell(title: "Downloads", value: gb(usage.pinnedBytes))
|
||||||
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3)
|
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3)
|
||||||
@@ -556,6 +603,9 @@ struct AppearanceScreen: View {
|
|||||||
.tint(Color(hex: 0x34C759))
|
.tint(Color(hex: 0x34C759))
|
||||||
.themedRow()
|
.themedRow()
|
||||||
} header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") }
|
} header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") }
|
||||||
|
Section {
|
||||||
|
NavigationLink(value: Route.TabBar) { Text("Tab Bar").foregroundStyle(c.label) }.themedRow()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.insetGrouped)
|
.listStyle(.insetGrouped)
|
||||||
.themedList()
|
.themedList()
|
||||||
@@ -563,3 +613,111 @@ struct AppearanceScreen: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- tab bar editor ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct TabBarScreen: View {
|
||||||
|
@Environment(\.palette) private var c
|
||||||
|
private var g: AppGraph { .shared }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var s = g.settings
|
||||||
|
let shown = Tab.layout(s.tabLayout)
|
||||||
|
let hidden = Tab.allCases.filter { !shown.contains($0) }
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
ForEach(shown, id: \.self) { t in
|
||||||
|
HStack {
|
||||||
|
Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label)
|
||||||
|
if t == .Settings { Spacer(); Text("Always on").font(.footnote).foregroundStyle(c.label2) }
|
||||||
|
}
|
||||||
|
.themedRow()
|
||||||
|
.deleteDisabled(t == .Settings)
|
||||||
|
}
|
||||||
|
.onMove { from, to in
|
||||||
|
var l = shown
|
||||||
|
l.move(fromOffsets: from, toOffset: to)
|
||||||
|
s.tabLayout = Tab.encode(l)
|
||||||
|
}
|
||||||
|
.onDelete { idx in
|
||||||
|
s.tabLayout = Tab.encode(shown.enumerated().filter { !idx.contains($0.offset) }.map(\.element))
|
||||||
|
}
|
||||||
|
} header: { Header(text: "In the bar") } footer: { Footer(text: "Drag to reorder, swipe to remove. The bar holds \(Tab.maxVisible) tabs.") }
|
||||||
|
if !hidden.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(hidden, id: \.self) { t in
|
||||||
|
Button { s.tabLayout = Tab.encode(shown + [t]) } label: {
|
||||||
|
HStack {
|
||||||
|
Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "plus.circle.fill").foregroundStyle(shown.count < Tab.maxVisible ? c.accent : c.label2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(shown.count >= Tab.maxVisible)
|
||||||
|
.themedRow()
|
||||||
|
}
|
||||||
|
} header: { Header(text: "Available") }
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button("Reset to default") { s.tabLayout = Tab.encode(Tab.defaultLayout) }.themedRow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.editMode, .constant(.active))
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.themedList()
|
||||||
|
.navigationTitle("Tab Bar")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---- debug ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private struct DebugCells: View {
|
||||||
|
@AppStorage(Log.verboseKey) private var verbose = false
|
||||||
|
@State private var logURL: URL?
|
||||||
|
@State private var viewing = false
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Toggle(isOn: $verbose) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
SettingsIcon(symbol: "ladybug.fill", bg: 0xFF9500)
|
||||||
|
Text("Verbose Logging").foregroundStyle(p.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(Color(hex: 0x34C759))
|
||||||
|
.themedRow()
|
||||||
|
Button { viewing = true } label: { Text("View Log").foregroundStyle(p.label) }.themedRow()
|
||||||
|
if let logURL {
|
||||||
|
ShareLink(item: logURL) { Text("Export Log").foregroundStyle(p.accent) }.themedRow()
|
||||||
|
} else {
|
||||||
|
Button { logURL = Log.shared.export() } label: { Text("Prepare Log for Export").foregroundStyle(p.accent) }.themedRow()
|
||||||
|
}
|
||||||
|
Button(role: .destructive) { Log.shared.clear(); logURL = nil } label: { Text("Clear Log") }.themedRow()
|
||||||
|
.sheet(isPresented: $viewing) { LogViewer() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LogViewer: View {
|
||||||
|
@State private var text = Log.shared.text()
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
Text(text.isEmpty ? "Nothing logged yet." : text)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
}
|
||||||
|
.defaultScrollAnchor(.bottom)
|
||||||
|
.navigationTitle("Log")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } }
|
||||||
|
ToolbarItem(placement: .primaryAction) { Button("Refresh") { text = Log.shared.text() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -6,8 +6,8 @@ options:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
MARKETING_VERSION: "1.0.12"
|
MARKETING_VERSION: "1.0.13"
|
||||||
CURRENT_PROJECT_VERSION: "13"
|
CURRENT_PROJECT_VERSION: "14"
|
||||||
targets:
|
targets:
|
||||||
Stemify:
|
Stemify:
|
||||||
type: application
|
type: application
|
||||||
@@ -32,7 +32,7 @@ targets:
|
|||||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIBackgroundModes: [audio, fetch]
|
UIBackgroundModes: [audio, fetch]
|
||||||
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
|
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
||||||
CADisableMinimumFrameDurationOnPhone: true
|
CADisableMinimumFrameDurationOnPhone: true
|
||||||
ITSAppUsesNonExemptEncryption: false
|
ITSAppUsesNonExemptEncryption: false
|
||||||
NSLocalNetworkUsageDescription: Stemify talks to Plex and the stems server on your home network.
|
NSLocalNetworkUsageDescription: Stemify talks to Plex and the stems server on your home network.
|
||||||
|
|||||||
@@ -15,6 +15,24 @@
|
|||||||
"tintColor": "#FA2D48",
|
"tintColor": "#FA2D48",
|
||||||
"category": "entertainment",
|
"category": "entertainment",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.13",
|
||||||
|
"buildVersion": "14",
|
||||||
|
"date": "2026-10-20T13:42:31-07:00",
|
||||||
|
"localizedDescription": "- Export and import of stem playlists and sound settings (Settings > Backup)\n- Mixer: one-line faders, every stem fits without scrolling\n- Wide and landscape now-playing layout for iPhone Duo\n\nUnsigned IPA: re-sign with Sideloadly or AltStore.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.13/stemify-1.0.13.ipa",
|
||||||
|
"size": 1559179,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "1.0.12",
|
||||||
|
"buildVersion": "13",
|
||||||
|
"date": "2026-10-20T13:29:05-07:00",
|
||||||
|
"localizedDescription": "More space between the seek bar and the transport buttons on Now Playing. Unsigned IPA: re-sign with Sideloadly, AltStore, SideStore or Feather.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.12/stemify-1.0.12.ipa",
|
||||||
|
"size": 1529939,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.11",
|
"version": "1.0.11",
|
||||||
"buildVersion": "12",
|
"buildVersion": "12",
|
||||||
|
|||||||
Reference in new issue
Block a user