Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75c6d89608 | ||
|
|
f065c95422 | ||
|
|
c17d6f174b | ||
|
|
e7e4fcc9d6 | ||
|
|
926ad6ee99 | ||
|
|
a0fe35a8d8 | ||
|
|
d773b3e5a5 | ||
|
|
9c1e70da4d | ||
|
|
4e2816673d | ||
|
|
9646b4c010 |
No files matched your search
@@ -171,6 +171,9 @@ CarPlay and Siri need the `carplay-audio` and `siri` entitlements, which a free
|
||||
|
||||
- **Now Playing:** shuffle and repeat sit either side of the play controls; tap repeat again for repeat one. The speed button (next to the mixer) goes from 0.5× to 2× without changing pitch.
|
||||
- **Mixer:** on Now Playing, under the waveform. Tap **Save** to keep a mix for that song; it comes back every time the song plays.
|
||||
- **AirPlay:** the AirPlay button on Now Playing picks the speaker or TV. Stems are mixed on the phone, so mixer changes apply live while it plays over AirPlay.
|
||||
- **Tab bar:** Settings → Tab Bar. Pick and reorder up to five of Library, Songs, Albums, Artists, Genres, Playlists, Search, Downloads and Settings. Settings always stays.
|
||||
- **Debug logs:** Settings → Debug. Turn on Verbose Logging, then View, Export or Clear the log. Export opens the share sheet with the app version, device, iOS version and audio output at the top. Tokens are stripped from the log.
|
||||
- **Karaoke:** the mic button mutes vocals on every song and shows synced lyrics from [LRCLIB](https://lrclib.net). Tap a line to jump to it. Vocals can be On, Soft or Off, and Soft has a slider for how quiet.
|
||||
- **Playlists:** the Playlists tab switches between Plex and Stem.
|
||||
- Plex playlists sync with Plex and Plexamp.
|
||||
|
||||
@@ -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 = [
|
||||
"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) } }
|
||||
|
||||
@@ -369,7 +369,7 @@ final class StemDecoder: @unchecked Sendable {
|
||||
}
|
||||
} catch {
|
||||
if !released {
|
||||
NSLog("StemDecoder %@ failed: %@", name, error.localizedDescription)
|
||||
Log.shared.error("decoder", "\(name) failed: \(error.localizedDescription)")
|
||||
state.with { _error = error }
|
||||
}
|
||||
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).
|
||||
func report(_ text: String) {
|
||||
Log.shared.error("report", text)
|
||||
message = AppMessage(text: text)
|
||||
messageListeners.values.forEach { $0(text) }
|
||||
}
|
||||
@@ -797,6 +798,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
guard let self, let info = n.userInfo,
|
||||
let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
|
||||
Log.shared.info("session", "interruption \(type == .began ? "began" : "ended")")
|
||||
switch type {
|
||||
case .began:
|
||||
// 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
|
||||
guard let self, let raw = n.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
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.
|
||||
if reason == .oldDeviceUnavailable && self.playWhenReady { self.pause() }
|
||||
self.eq.refreshRoute()
|
||||
}
|
||||
nc.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Log.shared.error("session", "media services reset")
|
||||
let was = self.playWhenReady
|
||||
let pos = self.positionMs()
|
||||
self.currentToken = -1
|
||||
|
||||
@@ -70,6 +70,24 @@ final class StemPlaylists {
|
||||
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) {
|
||||
guard let i = lists.firstIndex(where: { $0.id == id }) else { return }
|
||||
change(&lists[i])
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
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
|
||||
|
||||
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 {
|
||||
switch self {
|
||||
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 .Search: "magnifyingglass"
|
||||
case .Downloads: "arrow.down.circle.fill"
|
||||
@@ -15,10 +36,10 @@ enum Tab: String, CaseIterable, 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 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.
|
||||
@@ -47,13 +68,30 @@ final class Ui {
|
||||
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 }
|
||||
|
||||
/// Switches to the Library tab and pushes [r] there.
|
||||
func showInLibrary(_ r: Route) {
|
||||
nowPlayingOpen = false
|
||||
tab = .Library
|
||||
paths[.Library, default: []].append(r)
|
||||
let t = home
|
||||
tab = t
|
||||
paths[t, default: []].append(r)
|
||||
}
|
||||
|
||||
/// Switches to Settings and opens [r] (the Equalizer) there.
|
||||
|
||||
@@ -9,7 +9,7 @@ struct RootView: View {
|
||||
let s = g.settings
|
||||
let p = Palette.of(s.theme, s.accent)
|
||||
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)) {
|
||||
root(t).miniPlayerRoom()
|
||||
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
|
||||
@@ -20,6 +20,7 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
.modifier(GlassMiniPlayerHost())
|
||||
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
|
||||
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
||||
NowPlayingScreen()
|
||||
.overlayHost(ui.overlays, level: 1)
|
||||
@@ -41,6 +42,10 @@ struct RootView: View {
|
||||
@ViewBuilder private func root(_ t: Tab) -> some View {
|
||||
switch t {
|
||||
case .Library: LibraryHome()
|
||||
case .Songs: SongsScreen()
|
||||
case .Albums: AlbumsScreen()
|
||||
case .Artists: ArtistsScreen()
|
||||
case .Genres: GenresScreen()
|
||||
case .Playlists: PlaylistsScreen()
|
||||
case .Search: SearchScreen()
|
||||
case .Downloads: DownloadsScreen()
|
||||
@@ -54,6 +59,10 @@ struct RootView: View {
|
||||
case .Albums: AlbumsScreen()
|
||||
case .Songs: SongsScreen()
|
||||
case .Playlists: PlaylistsScreen()
|
||||
case .Genres: GenresScreen()
|
||||
case .Downloads: DownloadsScreen()
|
||||
case .TabBar: TabBarScreen()
|
||||
case .GenrePage(let g): GenreScreen(genre: g)
|
||||
case .Eq: EqScreen()
|
||||
case .EqFilters(let id): EqFiltersScreen(profileId: id)
|
||||
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 c = tint.map { base.tinted($0) } ?? base
|
||||
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 {
|
||||
Group {
|
||||
switch panel {
|
||||
case .Art: ArtPanel(thumb: track.thumb, playing: pm.playWhenReady)
|
||||
case .Mixer: VStack(spacing: 0) { MiniHeader(track: track); MixerPanel() }
|
||||
case .Queue: VStack(spacing: 0) { MiniHeader(track: track); QueuePanel() }
|
||||
case .Lyrics: VStack(spacing: 0) { MiniHeader(track: track); LyricsPanel(track: track) }
|
||||
}
|
||||
if wide {
|
||||
HStack(spacing: 0) {
|
||||
panelView(track, header: false)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
||||
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)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.96)))
|
||||
controls(track)
|
||||
controls(track, wide: false).frame(maxWidth: 560)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
} 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)
|
||||
@@ -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 c = tint.map { base.tinted($0) } ?? base
|
||||
return VStack(spacing: 0) {
|
||||
if panel == .Art {
|
||||
if wide || panel == .Art {
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 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)))
|
||||
}
|
||||
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 {
|
||||
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
||||
Spacer()
|
||||
@@ -178,6 +199,7 @@ struct NowPlayingScreen: View {
|
||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
||||
SpeedMenu()
|
||||
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: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
||||
}
|
||||
@@ -435,7 +457,7 @@ struct ProgressVolumeBar: View {
|
||||
TimelineView(.animation(paused: !pm.playWhenReady || scrub != nil)) { _ in
|
||||
let pos = scrub.map { Int64($0 * Double(dur)) } ?? pm.positionMs()
|
||||
let frac = dur > 0 ? min(max(Double(pos) / Double(dur), 0), 1) : 0
|
||||
VStack(spacing: 4) {
|
||||
VStack(spacing: 12) {
|
||||
Canvas { ctx, size in
|
||||
let barW = 2.7, pitch = 4.6
|
||||
let count = max(8, Int(size.width / pitch))
|
||||
@@ -561,6 +583,9 @@ private struct MixerPanel: View {
|
||||
let pm = g.playback
|
||||
let names = pm.stemNames
|
||||
let stems = pm.source == .Stems
|
||||
GeometryReader { geo in
|
||||
// Spread spare height across the rows (stems + master) instead of leaving it all at the bottom.
|
||||
let rowH = min(62, max(46, (geo.size.height - 80) / CGFloat(names.count + 1)))
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if pm.source == .Original {
|
||||
@@ -600,21 +625,14 @@ private struct MixerPanel: View {
|
||||
let levels = stems ? meters.step(pm.engine, names.count) : []
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(names.enumerated()), id: \.element) { i, name in
|
||||
StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems)
|
||||
StemFader(name: name, setting: pm.mix[name] ?? StemSetting(), level: i < levels.count ? levels[i] : 0, enabled: stems, height: rowH)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.separator.frame(height: 0.5).padding(.vertical, 10)
|
||||
HStack {
|
||||
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)
|
||||
p.separator.frame(height: 0.5).padding(.vertical, 6)
|
||||
FaderRow(dot: nil, label: "Master", percent: Int(pm.master * 100), dim: false, height: rowH) {
|
||||
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: 16)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
@@ -622,6 +640,7 @@ private struct MixerPanel: View {
|
||||
.scrollIndicators(.hidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
|
||||
private struct PresetChip: View {
|
||||
@@ -650,22 +669,14 @@ private struct StemFader: View {
|
||||
let setting: StemSetting
|
||||
let level: Float
|
||||
let enabled: Bool
|
||||
var height: CGFloat = 46
|
||||
@Environment(\.palette) private var p
|
||||
private var pm: PlaybackManager { AppGraph.shared.playback }
|
||||
|
||||
var body: some View {
|
||||
let color = Color(hex: Stems.color(name))
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(spacing: 8) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
FaderRow(dot: setting.muted ? p.label3 : color, label: Stems.label(name), percent: Int(stemTaper(setting.level) * 100), dim: setting.muted, height: height) {
|
||||
VStack(spacing: 0) {
|
||||
FaderSlider(value: setting.level, fill: setting.muted ? p.label3 : color, detent: StemSetting.unity, enabled: enabled) {
|
||||
pm.setStemLevel(name, $0)
|
||||
}
|
||||
@@ -680,7 +691,40 @@ private struct StemFader: View {
|
||||
.frame(height: 3)
|
||||
.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
|
||||
var height: CGFloat = 46
|
||||
@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: height)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// A Settings.app row that pushes [route]: icon, title, grey value, chevron.
|
||||
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)
|
||||
ToggleCell(title: "Download on Wi-Fi Only", icon: "arrow.down.circle.fill", iconBg: 0x30B0C7, isOn: $s.downloadsWifiOnly)
|
||||
} 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 {
|
||||
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
|
||||
} 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 -----------------------------------------------------------------------------------
|
||||
|
||||
struct PlexAccountScreen: View {
|
||||
@@ -466,7 +513,7 @@ struct StorageScreen: View {
|
||||
let usage = g.cache.usage
|
||||
List {
|
||||
Section {
|
||||
Button { ui.selectTab(.Downloads) } label: {
|
||||
Button { ui.openDownloads() } label: {
|
||||
HStack {
|
||||
ValueCell(title: "Downloads", value: gb(usage.pinnedBytes))
|
||||
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3)
|
||||
@@ -556,6 +603,9 @@ struct AppearanceScreen: View {
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
} 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)
|
||||
.themedList()
|
||||
@@ -563,3 +613,111 @@ struct AppearanceScreen: View {
|
||||
.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:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
MARKETING_VERSION: "1.0.12"
|
||||
CURRENT_PROJECT_VERSION: "13"
|
||||
MARKETING_VERSION: "1.0.16"
|
||||
CURRENT_PROJECT_VERSION: "17"
|
||||
targets:
|
||||
Stemify:
|
||||
type: application
|
||||
@@ -32,7 +32,7 @@ targets:
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
UILaunchScreen: {}
|
||||
UIBackgroundModes: [audio, fetch]
|
||||
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait]
|
||||
UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight]
|
||||
CADisableMinimumFrameDurationOnPhone: true
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
NSLocalNetworkUsageDescription: Stemify talks to Plex and the stems server on your home network.
|
||||
|
||||
@@ -15,6 +15,42 @@
|
||||
"tintColor": "#FA2D48",
|
||||
"category": "entertainment",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.15",
|
||||
"buildVersion": "16",
|
||||
"date": "2026-10-20T14:22:36-07:00",
|
||||
"localizedDescription": "Mixer faders use the spare height on Now Playing, with more room above the seek bar. Unsigned IPA for Sideloadly, AltStore, SideStore or Feather.",
|
||||
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.15/stemify-1.0.15.ipa",
|
||||
"size": 1626425,
|
||||
"minOSVersion": "17.0"
|
||||
},
|
||||
{
|
||||
"version": "1.0.14",
|
||||
"buildVersion": "15",
|
||||
"date": "2026-10-20T14:14:26-07:00",
|
||||
"localizedDescription": "Reorderable tab bar (Songs, Albums, Artists, Genres, Playlists, Search, Downloads), AirPlay route picker in Now Playing, debug logging with log export in Settings, more room above the seeker. Unsigned IPA.",
|
||||
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.14/stemify-1.0.14.ipa",
|
||||
"size": 1625201,
|
||||
"minOSVersion": "17.0"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"buildVersion": "12",
|
||||
|
||||
Reference in new issue
Block a user