Author SHA1 Message Date
cdnutterandClaude Opus 5.5 65bc69a7fe ios: sort artists, albums, songs and playlists (1.0.5)
Plexamp-style sort menu on each library list: pick a field, pick it again
to flip direction. Choice is saved per screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:23:38 -07:00
cdnutterandClaude Opus 5.5 d149af336a iOS: keep older build numbers when regenerating the source
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:18:42 -07:00
cdnutterandClaude Opus 5.5 3f7fd76762 iOS: source for 1.0.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:18:33 -07:00
cdnutterandClaude Opus 5.5 bea3f8f5a2 iOS 1.0.4: per-song saved mixes, karaoke mode with synced lyrics
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:18:31 -07:00
cdnutterandClaude Opus 5.5 e0184f540b iOS: add AltStore source for 1.0.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:08:22 -07:00
10 changed files with 444 additions and 32 deletions

No files matched your search

+2
View File
@@ -363,6 +363,8 @@ adb forward tcp:5277 tcp:5277
`ios/` is a SwiftUI port of the Android app with the same features, plus CarPlay (browse tabs, Now Playing with Vocals and Mix buttons) and Siri ("play X in Stemify"). iOS 17 or newer.
On top of the Android features: the Save chip in the mixer remembers a mix for that song and puts it back every time it plays, and the mic button turns on karaoke mode (vocals muted on every song, synced lyrics from [LRCLIB](https://lrclib.net) on Now Playing; tap a line to jump to it).
Build an unsigned IPA on a Mac with Xcode and xcodegen (`brew install xcodegen`):
```
+86
View File
@@ -0,0 +1,86 @@
import Foundation
struct LyricLine: Hashable {
let ms: Int64
let text: String
}
struct Lyrics {
var lines: [LyricLine] = []
/// False for plain lyrics, which have no timings (every [ms] is 0).
var synced = false
var instrumental = false
}
/// Lyrics from LRCLIB (lrclib.net), synced when it has them. Hits are cached on disk by track,
/// misses only until the app restarts.
actor LyricsSource {
static let shared = LyricsSource()
private struct Record: Codable {
var syncedLyrics: String?
var plainLyrics: String?
var instrumental: Bool?
var duration: Double?
}
private let dir: URL
private var misses: Set<String> = []
private init() {
dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("lyrics", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
/// Nil when LRCLIB has nothing for this song or couldn't be reached.
func lyrics(_ t: Track) async -> Lyrics? {
let file = dir.appendingPathComponent(t.ratingKey.replacingOccurrences(of: "/", with: "_") + ".json")
if let data = try? Data(contentsOf: file), let r = try? JSONDecoder().decode(Record.self, from: data) { return Self.parse(r) }
if misses.contains(t.ratingKey) { return nil }
let secs = Double(t.durationMs) / 1000
var rec = await get("get", ["track_name": t.title, "artist_name": t.artist, "album_name": t.album, "duration": String(Int(secs.rounded()))])
.flatMap { try? JSONDecoder().decode(Record.self, from: $0) }
if rec == nil, let data = await get("search", ["track_name": t.title, "artist_name": t.artist]),
let found = try? JSONDecoder().decode([Record].self, from: data) {
// Closest length first, so a live or extended version doesn't win.
let close = found.filter { abs(($0.duration ?? 0) - secs) < 8 || secs <= 0 }
rec = close.first { $0.syncedLyrics?.isEmpty == false } ?? close.first { $0.plainLyrics?.isEmpty == false } ?? close.first
}
guard let rec else {
misses.insert(t.ratingKey)
return nil
}
if let data = try? JSONEncoder().encode(rec) { try? data.write(to: file, options: .atomic) }
return Self.parse(rec)
}
private func get(_ path: String, _ query: [String: String]) async -> Data? {
var c = URLComponents(string: "https://lrclib.net/api/\(path)")!
c.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
var req = URLRequest(url: c.url!, timeoutInterval: 10)
req.setValue("Stemify iOS (https://git.iamchrisama.com/cdnutter/stemify)", forHTTPHeaderField: "User-Agent")
guard let (data, resp) = try? await URLSession.shared.data(for: req), (resp as? HTTPURLResponse)?.statusCode == 200 else { return nil }
return data
}
private static let stamp = #/\[(\d+):(\d+(?:\.\d+)?)\]/#
/// LRC: each line starts with one or more [mm:ss.xx] stamps.
private static func parse(_ r: Record) -> Lyrics {
if let lrc = r.syncedLyrics, !lrc.isEmpty {
var lines: [LyricLine] = []
for raw in lrc.split(separator: "\n", omittingEmptySubsequences: false) {
let stamps = raw.matches(of: stamp)
guard let last = stamps.last else { continue }
let text = raw[last.range.upperBound...].trimmingCharacters(in: .whitespaces)
for m in stamps {
let ms = (Int64(m.output.1) ?? 0) * 60_000 + Int64((Double(m.output.2) ?? 0) * 1000)
lines.append(LyricLine(ms: ms, text: text))
}
}
return Lyrics(lines: lines.sorted { $0.ms < $1.ms }, synced: true)
}
let plain = (r.plainLyrics ?? "").split(separator: "\n", omittingEmptySubsequences: false).map { LyricLine(ms: 0, text: String($0)) }
return Lyrics(lines: plain.count > 1 || plain.first?.text.isEmpty == false ? plain : [], instrumental: r.instrumental == true)
}
}
+46 -2
View File
@@ -106,6 +106,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
@ObservationIgnored private let eq: EqController
@ObservationIgnored private(set) var engine: StemEngine!
@ObservationIgnored private let queueFile: URL
@ObservationIgnored private let songMixFile: URL
private(set) var queue: [QueueItem] = []
private(set) var index = -1
@@ -121,6 +122,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
private(set) var waveform: Waveform?
private(set) var durationMs: Int64 = 0
private(set) var message: AppMessage?
/// Mixes saved for single songs (by rating key), put back whenever that song starts.
private(set) var songMixes: [String: [String: StemSetting]] = [:]
/// Karaoke mode: vocals out on every song, lyrics on Now Playing.
private(set) var karaoke = false
@ObservationIgnored private var preKaraokeMix: [String: StemSetting]?
@ObservationIgnored private var listeners: [UUID: () -> Void] = [:]
@ObservationIgnored private var messageListeners: [UUID: (String) -> Void] = [:]
@@ -148,6 +154,8 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
self.cache = cache
self.eq = eq
queueFile = dir.appendingPathComponent("queue.json")
songMixFile = dir.appendingPathComponent("song-mixes.json")
songMixes = (try? JSONDecoder().decode([String: [String: StemSetting]].self, from: Data(contentsOf: songMixFile))) ?? [:]
master = settings.masterVolume
engine = StemEngine(listener: self)
eq.onParams = { [weak self] p in self?.engine.eqParams = p }
@@ -405,6 +413,42 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
pushMix()
}
/// The mix a song starts with: karaoke if that's on, else the song's saved mix, else cleared (or kept, per settings).
private func startMix(_ t: Track) -> [String: StemSetting] {
if karaoke { return Self.karaokeMix }
return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:])
}
private static let karaokeMix = ["vocals": StemSetting(muted: true)]
/// Turning it off puts back the song's saved mix, or the one from before, unless the mix was changed meanwhile.
func setKaraoke(_ on: Bool) {
guard on != karaoke else { return }
karaoke = on
if on {
preKaraokeMix = mix
applyMix(Self.karaokeMix)
} else {
if MixPresets.normalized(mix) == Self.karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) }
preKaraokeMix = nil
changed()
}
}
var songMix: [String: StemSetting]? { current.flatMap { songMixes[$0.track.ratingKey] } }
func saveSongMix() { setSongMix(MixPresets.normalized(mix)) }
func forgetSongMix() { setSongMix(nil) }
private func setSongMix(_ m: [String: StemSetting]?) {
guard let key = current?.track.ratingKey else { return }
songMixes[key] = m
let all = songMixes, file = songMixFile
DispatchQueue.global(qos: .utility).async {
if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) }
}
}
private func editMix(_ name: String, _ f: (inout StemSetting) -> Void) {
var s = mix[name] ?? StemSetting()
f(&s)
@@ -462,7 +506,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
if play && !activateSession() { return }
playWhenReady = play
buffering = true
if !settings.keepStemMix { mix = [:] }
mix = startMix(item.track)
changed()
save()
loadTask = Task { @MainActor [weak self] in
@@ -635,7 +679,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
guard let item = current else { return }
durationMs = item.track.durationMs
waveform = nil
if !settings.keepStemMix { mix = [:] }
mix = startMix(item.track)
source = currentEntry != nil ? .Stems : .Original
stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"])
pushMix()
+7 -7
View File
@@ -113,8 +113,8 @@ final class PlexClient: @unchecked Sendable {
// ---- library ------------------------------------------------------------------
func artists() async throws -> [Artist] {
try await all("/library/sections/\(section)/all", [("type", 8), ("sort", "titleSort")]).map { $0.toArtist() }
func artists(sort: String = "titleSort") async throws -> [Artist] {
try await all("/library/sections/\(section)/all", [("type", 8), ("sort", sort)]).map { $0.toArtist() }
}
func albums(sort: String = "titleSort") async throws -> [Album] {
@@ -126,9 +126,9 @@ final class PlexClient: @unchecked Sendable {
.metadata.map { $0.toAlbum() }
}
/// One page of every track, sorted by title. Returns the page and the library's track count.
func tracksPage(start: Int, size: Int) async throws -> ([Track], Int) {
let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", "titleSort")], start: start, size: size)
/// One page of every track. Returns the page and the library's track count.
func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) {
let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", sort)], start: start, size: size)
return (c.metadata.map { $0.toTrack() }, c.totalSize ?? c.size)
}
@@ -190,8 +190,8 @@ final class PlexClient: @unchecked Sendable {
// ---- playlists ----------------------------------------------------------------
func playlists() async throws -> [Playlist] {
try await get("/playlists", [("playlistType", "audio")]).metadata.map { $0.toPlaylist() }
func playlists(sort: String = "titleSort") async throws -> [Playlist] {
try await get("/playlists", [("playlistType", "audio"), ("sort", sort)]).metadata.map { $0.toPlaylist() }
}
func playlistTracks(_ key: String) async throws -> [Track] {
+142 -16
View File
@@ -111,9 +111,131 @@ private struct LibraryRow: View {
// ---- artists --------------------------------------------------------------------------------
// ---- sorting ---------------------------------------------------------------------------------
/// One way to sort a list. `key` is Plex's sort key; `desc` is the direction it starts in.
struct SortOption {
let title: String
let key: String
var desc = false
var up = "Ascending"
var down = "Descending"
/// Plex reverses a compound key by its first field.
func param(_ reversed: Bool) -> String {
guard desc != reversed else { return key }
var fields = key.split(separator: ",").map(String.init)
fields[0] += ":desc"
return fields.joined(separator: ",")
}
}
private let AZ = (up: "A to Z", down: "Z to A")
private let NEW = (up: "Oldest First", down: "Newest First")
private let MOST = (up: "Fewest First", down: "Most First")
let ARTIST_SORTS = [
SortOption(title: "Name", key: "titleSort", up: AZ.up, down: AZ.down),
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
]
let ALBUM_SORTS = [
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
SortOption(title: "Artist", key: "artist.titleSort,year", up: AZ.up, down: AZ.down),
SortOption(title: "Year", key: "year", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Release Date", key: "originallyAvailableAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
]
let SONG_SORTS = [
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
SortOption(title: "Artist", key: "artist.titleSort,album.titleSort,album.index,album.id,track.absoluteIndex", up: AZ.up, down: AZ.down),
SortOption(title: "Album", key: "album.titleSort,album.index,album.id,track.absoluteIndex", up: AZ.up, down: AZ.down),
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"),
]
let PLAYLIST_SORTS = [
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Updated", key: "updatedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"),
]
/// A screen's sort choice, kept across launches under `sort.<name>`.
struct SortState: DynamicProperty {
let options: [SortOption]
@AppStorage private var index: Int
@AppStorage private var reversed: Bool
init(_ name: String, _ options: [SortOption]) {
self.options = options
_index = AppStorage(wrappedValue: 0, "sort.\(name)")
_reversed = AppStorage(wrappedValue: false, "sort.\(name).rev")
}
var current: Int { options.indices.contains(index) ? index : 0 }
var option: SortOption { options[current] }
var param: String { option.param(reversed) }
/// The default order (first option, its own direction), where a letter index makes sense.
var isDefault: Bool { current == 0 && !reversed }
/// Picking the current field again flips it, like Plexamp.
func pick(_ i: Int) {
if i == current { reversed.toggle() } else { index = i; reversed = false }
}
func setDescending(_ d: Bool) { reversed = d != option.desc }
var descending: Bool { option.desc != reversed }
}
/// Toolbar menu: the sort fields, then the direction for the chosen one.
struct SortMenu: View {
let sort: SortState
var body: some View {
Menu {
Section("Sort By") {
ForEach(sort.options.indices, id: \.self) { i in
Button { sort.pick(i) } label: {
if i == sort.current {
Label(sort.options[i].title, systemImage: sort.descending ? "chevron.down" : "chevron.up")
} else {
Text(sort.options[i].title)
}
}
}
}
Section {
Button { sort.setDescending(false) } label: {
if sort.descending { Text(sort.option.up) } else { Label(sort.option.up, systemImage: "checkmark") }
}
Button { sort.setDescending(true) } label: {
if sort.descending { Label(sort.option.down, systemImage: "checkmark") } else { Text(sort.option.down) }
}
}
} label: {
Image(systemName: "arrow.up.arrow.down")
}
}
}
// ---- artists ---------------------------------------------------------------------------------
struct ArtistsScreen: View {
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
private let sort = SortState("artists", ARTIST_SORTS)
@State private var artists: LoadState<[Artist]> = .loading
@State private var reload = 0
@@ -135,14 +257,15 @@ struct ArtistsScreen: View {
}
.background(p.bg)
.overlay(alignment: .trailing) {
if let list = artists.value, list.count > 30 {
if sort.isDefault, let list = artists.value, list.count > 30 {
SectionIndex(titles: list.map(\.sortTitle)) { i in proxy.scrollTo(list[i].ratingKey, anchor: .top) }
}
}
}
.navigationTitle("Artists")
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
await LoadState.load($artists) { try await AppGraph.shared.plex.artists() }
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") {
await LoadState.load($artists) { try await AppGraph.shared.plex.artists(sort: sort.param) }
}
}
}
@@ -195,11 +318,9 @@ private struct SectionIndex: View {
// ---- albums ---------------------------------------------------------------------------------
private let ALBUM_SORTS = [("Title", "titleSort"), ("Artist", "artist.titleSort,year"), ("Recently Added", "addedAt:desc"), ("Year", "year:desc")]
struct AlbumsScreen: View {
@Environment(Ui.self) private var ui
@State private var sort = 0
private let sort = SortState("albums", ALBUM_SORTS)
@State private var albums: LoadState<[Album]> = .loading
@State private var reload = 0
@@ -210,16 +331,10 @@ struct AlbumsScreen: View {
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Sort") {
ui.overlays.actions("Sort By", ALBUM_SORTS.enumerated().map { i, s in
SheetAction(i == sort ? "✓ \(s.0)" : s.0) { sort = i }
})
}
}
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
}
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort)|\(reload)") {
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: ALBUM_SORTS[sort].1) }
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") {
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: sort.param) }
}
}
}
@@ -233,6 +348,9 @@ struct SongsScreen: View {
@State private var total = -1
@State private var loading = false
@State private var error: String?
/// Bumped when the sort changes, so a page still loading for the old order is dropped.
@State private var gen = 0
private let sort = SortState("songs", SONG_SORTS)
private var g: AppGraph { .shared }
/// Shuffles the whole library, not just the pages loaded so far.
@@ -264,17 +382,25 @@ struct SongsScreen: View {
if loading { LoadingRow() }
if let error { ErrorRow(message: error) { loadMore() } }
}
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
.task { if tracks.isEmpty { loadMore() } }
.onChange(of: sort.param) {
gen += 1; tracks = []; total = -1; error = nil; loading = false
loadMore()
}
}
private func loadMore() {
if loading || (total >= 0 && tracks.count >= total) { return }
loading = true
let mine = gen, order = sort.param
Task {
do {
let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200)
let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200, sort: order)
guard mine == gen else { return }
tracks += page; total = n; error = nil
} catch {
guard mine == gen else { return }
self.error = error.localizedDescription
}
loading = false
+98 -2
View File
@@ -1,7 +1,7 @@
import SwiftUI
import UIKit
enum NpPanel { case Art, Mixer, Queue }
enum NpPanel { case Art, Mixer, Queue, Lyrics }
func deviceIcon(_ d: OutputDevice) -> (String, UInt32) {
switch d.kind {
@@ -105,6 +105,7 @@ struct NowPlayingScreen: View {
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) }
}
}
.frame(maxHeight: .infinity)
@@ -123,6 +124,7 @@ struct NowPlayingScreen: View {
.tint(c.label)
.animation(.easeInOut(duration: 0.3), value: tint)
.presentationDragIndicator(.visible)
.onAppear { if g.playback.karaoke { panel = .Lyrics } }
.task(id: "\(track?.thumb ?? "")|\(g.settings.albumColorNowPlaying)|\(base.dark)") {
guard g.settings.albumColorNowPlaying, let thumb = track?.thumb else { tint = nil; return }
tint = await albumColor(thumb, dark: base.dark)
@@ -172,6 +174,7 @@ struct NowPlayingScreen: View {
HStack {
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
}
.frame(height: 48)
@@ -180,6 +183,16 @@ struct NowPlayingScreen: View {
}
/// Opens a panel, or goes back to the art if it's already open.
/// Karaoke: vocals out and lyrics up. If it's on but another panel is showing, brings the lyrics back first.
private func karaoke() {
let pm = g.playback
withAnimation(.spring(duration: 0.38, bounce: 0.12)) {
if pm.karaoke && panel != .Lyrics { panel = .Lyrics; return }
pm.setKaraoke(!pm.karaoke)
panel = pm.karaoke ? .Lyrics : .Art
}
}
private func show(_ to: NpPanel) {
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to }
}
@@ -490,6 +503,7 @@ private final class Meters {
}
private struct MixerPanel: View {
@Environment(Ui.self) private var ui
@Environment(\.palette) private var p
@State private var meters = Meters()
private var g: AppGraph { .shared }
@@ -510,6 +524,20 @@ private struct MixerPanel: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
let current = MixPresets.normalized(pm.mix)
let saved = pm.songMix.map(MixPresets.normalized)
let isSaved = saved == current
PresetChip(text: isSaved ? "Saved" : "Save", icon: isSaved ? "bookmark.fill" : "bookmark", selected: isSaved) {
if isSaved {
pm.forgetSongMix()
ui.overlays.hud("Removed this song's mix", "bookmark.slash")
} else {
pm.saveSongMix()
ui.overlays.hud("Mix saved for this song", "bookmark.fill")
}
}
if let saved, !isSaved {
PresetChip(text: "This Song", selected: false) { pm.applyMix(saved) }
}
ForEach(MixPresets.all) { preset in
if let target = preset.build(names) {
PresetChip(text: preset.label, selected: MixPresets.normalized(target) == current) { pm.applyMix(target) }
@@ -549,12 +577,17 @@ private struct MixerPanel: View {
/// [Chip] in the sheet's own colours: selected is the label colour, so it reads on any tint.
private struct PresetChip: View {
let text: String
var icon: String? = nil
let selected: Bool
let action: () -> Void
@Environment(\.palette) private var p
var body: some View {
Button(action: action) {
Text(text).font(.footnote.weight(.semibold)).foregroundStyle(selected ? p.bg : p.label)
HStack(spacing: 5) {
if let icon { Image(systemName: icon) }
Text(text)
}
.font(.footnote.weight(.semibold)).foregroundStyle(selected ? p.bg : p.label)
.padding(.horizontal, 14).frame(height: 32)
.background(selected ? p.label : p.fill, in: Capsule())
}
@@ -814,3 +847,66 @@ struct MiniPlayer: View {
}
}
}
/// Synced lyrics follow the song (tap a line to jump there); plain ones just scroll.
private struct LyricsPanel: View {
let track: Track
@Environment(\.palette) private var p
@State private var lyrics: Lyrics?
@State private var loading = true
@State private var at = -1
private var g: AppGraph { .shared }
var body: some View {
Group {
if loading {
ProgressView()
} else if let l = lyrics, !l.lines.isEmpty {
lines(l)
} else {
Text(lyrics?.instrumental == true ? "Instrumental" : "No lyrics found")
.font(.title3.weight(.semibold)).foregroundStyle(p.label2)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.task(id: track.ratingKey) {
loading = true
at = -1
lyrics = await LyricsSource.shared.lyrics(track)
loading = false
guard let l = lyrics, l.synced else { return }
while !Task.isCancelled {
// A little early, so the line lights up as it's sung rather than after.
let pos = g.playback.positionMs() + 250
let i = (l.lines.lastIndex { $0.ms <= pos }) ?? -1
if i != at { withAnimation(.easeOut(duration: 0.25)) { at = i } }
try? await Task.sleep(nanoseconds: 150_000_000)
}
}
}
private func lines(_ l: Lyrics) -> some View {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: l.synced ? 18 : 6) {
ForEach(Array(l.lines.enumerated()), id: \.offset) { i, line in
Text(line.text.isEmpty ? "\u{266A}" : line.text)
.font(l.synced ? .title2.weight(.bold) : .body)
.foregroundStyle(!l.synced || i == at ? p.label : p.label3)
.scaleEffect(i == at ? 1.03 : 1, anchor: .leading)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture { if l.synced { g.playback.seekTo(line.ms) } }
.id(i)
}
}
.padding(.horizontal, 28)
.padding(.vertical, l.synced ? 120 : 16)
}
.scrollIndicators(.hidden)
.onChange(of: at) { _, i in
if i >= 0 { withAnimation(.easeInOut(duration: 0.4)) { proxy.scrollTo(i, anchor: .center) } }
}
}
}
}
+4 -2
View File
@@ -6,6 +6,7 @@ struct PlaylistsScreen: View {
@Environment(Ui.self) private var ui
@State private var lists: LoadState<[Playlist]> = .loading
@State private var reload = 0
private let sort = SortState("playlists", PLAYLIST_SORTS)
private var g: AppGraph { .shared }
var body: some View {
@@ -21,12 +22,13 @@ struct PlaylistsScreen: View {
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
ToolbarItem(placement: .topBarTrailing) {
Button { newPlaylist() } label: { Image(systemName: "plus") }
}
}
.task(id: "\(g.settings.musicSection)|\(reload)") {
await LoadState.load($lists) { try await g.plex.playlists() }
.task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") {
await LoadState.load($lists) { try await g.plex.playlists(sort: sort.param) }
}
}
+2 -2
View File
@@ -6,8 +6,8 @@ options:
settings:
base:
SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.0.3"
CURRENT_PROJECT_VERSION: "4"
MARKETING_VERSION: "1.0.5"
CURRENT_PROJECT_VERSION: "6"
targets:
Stemify:
type: application
+7 -1
View File
@@ -19,7 +19,13 @@ def get(url):
yml = open(os.path.join(HERE, "..", "project.yml")).read()
builds = {} # only the current build number is known locally; older ones fall back to the version
# Build numbers: older ones from the last source.json, the current one from project.yml.
builds = {}
try:
for v in json.load(open(os.path.join(HERE, "..", "source.json")))["apps"][0]["versions"]:
builds[v["version"]] = v["buildVersion"]
except (OSError, ValueError, KeyError, IndexError):
pass
m = re.search(r'MARKETING_VERSION: "(.*)"', yml), re.search(r'CURRENT_PROJECT_VERSION: "(.*)"', yml)
builds[m[0][1]] = m[1][1]
+50
View File
@@ -0,0 +1,50 @@
{
"name": "Stemify",
"identifier": "org.cdnutter.stemify.source",
"sourceURL": "https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/source.json",
"iconURL": "https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
"website": "https://git.iamchrisama.com/cdnutter/stemify",
"apps": [
{
"name": "Stemify",
"bundleIdentifier": "org.cdnutter.stemify",
"developerName": "cdnutter",
"subtitle": "Plex music player with live stem mixing.",
"localizedDescription": "Plays your Plex library and lets you mute vocals or remix stems on the fly. Works offline with downloads, and in the car with CarPlay.",
"iconURL": "https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
"tintColor": "#FA2D48",
"category": "entertainment",
"versions": [
{
"version": "1.0.4",
"buildVersion": "5",
"date": "2026-10-20T09:18:33-07:00",
"localizedDescription": "Save a stem mix for a song (Save chip in the mixer); it comes back every time that song plays. New mic button for karaoke mode: vocals muted on every song plus synced lyrics from LRCLIB, tap a line to jump to it.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.4/stemify-1.0.4.ipa",
"size": 1355610,
"minOSVersion": "17.0"
},
{
"version": "1.0.3",
"buildVersion": "4",
"date": "2026-10-20T09:08:18-07:00",
"localizedDescription": "Bundle ID is now org.cdnutter.stemify (delete the old app first; settings won't carry over). IPA is named stemify-<version>.ipa. Adds an AltStore/SideStore/Feather source.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.3/stemify-1.0.3.ipa",
"size": 1319672,
"minOSVersion": "17.0"
}
],
"appPermissions": {
"entitlements": [
"com.apple.developer.carplay-audio",
"com.apple.developer.siri"
],
"privacy": {
"NSLocalNetworkUsageDescription": "Stemify talks to Plex and the stems server on your home network.",
"NSSiriUsageDescription": "Ask Siri to play music from your Plex library."
}
}
}
],
"news": []
}