3 Commits
Author SHA1 Message Date
cdnutterandClaude Opus 5.5 1123f460b0 iOS 1.0.3: bundle ID org.cdnutter.stemify, stemify-<version>.ipa, AltStore source generator
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 09:08:11 -07:00
cdnutterandClaude Opus 5.5 a0726b07a1 iOS 1.0.2: shuffle the whole library from Songs
Shuffle used only the pages loaded so far (the A and B songs). It now
asks Plex for 1000 random tracks from the whole library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:53:16 -07:00
cdnutterandClaude Opus 5.5 2ddfe051d8 iOS 1.0.1: mini player, Now Playing spacing, play spinner fix
- Mini player floats as a solid card and every screen leaves room for it,
  so it no longer covers the bottom of lists
- More space between title and scrubber; panel switches animate
- Play button no longer spins forever after changing tracks: the engine
  now always reports buffering on load
- Springier button presses and play/pause symbol transitions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:50:42 -07:00
9 changed files with 167 additions and 33 deletions

No files matched your search

+10 -2
View File
@@ -367,10 +367,18 @@ Build an unsigned IPA on a Mac with Xcode and xcodegen (`brew install xcodegen`)
```
cd ios
scripts/build-ipa.sh # writes build/Stemify.ipa
scripts/build-ipa.sh # writes build/stemify-<version>.ipa
```
Sideload `build/Stemify.ipa` with Sideloadly or AltStore, which sign it with your Apple ID. To work in Xcode instead, run `xcodegen generate` and open `Stemify.xcodeproj`.
Sideload the IPA with Sideloadly or AltStore, which sign it with your Apple ID.
To get updates in AltStore, SideStore or Feather, add this source:
```
https://git.iamchrisama.com/cdnutter/stemify/raw/branch/main/ios/source.json
```
After uploading a new release (tag `ios-v<version>`, asset `stemify-<version>.ipa`), run `ios/scripts/make-source.py` to regenerate `ios/source.json` and commit it. To work in Xcode instead, run `xcodegen generate` and open `Stemify.xcodeproj`.
CarPlay and Siri need the `carplay-audio` and `siri` entitlements. A free Apple ID can't sign those, so on a free sideload the app works on the phone but won't show up in CarPlay or Siri. A paid developer account covers Siri; CarPlay audio also needs Apple to approve the entitlement for your team.
+3
View File
@@ -298,6 +298,9 @@ final class StemEngine: @unchecked Sendable {
session = s
wantPlay = play
resetOutput(s)
// Starts out buffering so the step that begins playback always reports a change,
// even when the last track was already playing.
setFlags(false, play)
publish()
}
}
+5
View File
@@ -132,6 +132,11 @@ final class PlexClient: @unchecked Sendable {
return (c.metadata.map { $0.toTrack() }, c.totalSize ?? c.size)
}
/// Random tracks from the whole library, for shuffling it without loading every page.
func randomTracks(_ count: Int) async throws -> [Track] {
try await get("/library/sections/\(section)/all", [("type", 10), ("sort", "random")], start: 0, size: count).metadata.map { $0.toTrack() }
}
func album(_ key: String) async throws -> Album? {
try await get("/library/metadata/\(key)").metadata.first?.toAlbum()
}
+19 -3
View File
@@ -11,11 +11,14 @@ struct RootView: View {
TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) {
ForEach(Tab.allCases, id: \.self) { t in
NavigationStack(path: ui.path(t)) {
root(t).navigationDestination(for: Route.self) { destination($0) }
root(t).miniPlayerRoom()
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
if g.playback.current != nil { MiniPlayer() }
// Floats over the screens; each screen leaves room for it so nothing ends up underneath.
.overlay(alignment: .bottom) {
if g.playback.current != nil { MiniPlayer().transition(.move(edge: .bottom).combined(with: .opacity)) }
}
.animation(.spring(duration: 0.35), value: g.playback.current != nil)
.tabItem { Label(t.rawValue, systemImage: t.icon) }
.tag(t)
}
@@ -68,3 +71,16 @@ struct RootView: View {
}
}
}
private struct MiniPlayerRoom: ViewModifier {
private var g: AppGraph { .shared }
func body(content: Content) -> some View {
content.safeAreaInset(edge: .bottom, spacing: 0) {
if g.playback.current != nil { Color.clear.frame(height: 66) }
}
}
}
private extension View {
func miniPlayerRoom() -> some View { modifier(MiniPlayerRoom()) }
}
+9 -1
View File
@@ -235,6 +235,14 @@ struct SongsScreen: View {
@State private var error: String?
private var g: AppGraph { .shared }
/// Shuffles the whole library, not just the pages loaded so far.
private func shuffleAll() {
Task {
do { g.playback.playTracks(try await g.plex.randomTracks(1000), shuffle: true) }
catch { g.playback.report("Couldn't load songs: \(error.localizedDescription)") }
}
}
var body: some View {
Page(title: "Songs") {
if !tracks.isEmpty {
@@ -242,7 +250,7 @@ struct SongsScreen: View {
Text(songs(total)).font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
}
PlayShuffle(play: { g.playback.playTracks(Array(tracks.prefix(1000))) },
shuffle: { g.playback.playTracks(Array(tracks.prefix(1000)), shuffle: true) })
shuffle: shuffleAll)
.padding(.bottom, 12)
}
ForEach(Array(tracks.enumerated()), id: \.element.id) { i, t in
+36 -18
View File
@@ -108,8 +108,7 @@ struct NowPlayingScreen: View {
}
}
.frame(maxHeight: .infinity)
.transition(.opacity)
.animation(.easeInOut(duration: 0.18), value: panel)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
controls(track)
} else {
Text("Nothing Playing").font(.title3.weight(.semibold)).foregroundStyle(c.label2).frame(maxHeight: .infinity)
@@ -147,9 +146,10 @@ struct NowPlayingScreen: View {
EllipsisButton(items: ui.menu.track(track))
}
.padding(.top, 14)
.transition(.opacity.combined(with: .move(edge: .top)))
}
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
.padding(.top, 18)
.padding(.top, panel == .Art ? 34 : 18)
HStack {
Spacer()
TransportButton(symbol: "backward.fill", size: 34, action: pm.previous)
@@ -157,8 +157,9 @@ struct NowPlayingScreen: View {
Button(action: pm.togglePlay) {
ZStack {
if pm.buffering && pm.playWhenReady { ProgressView().controlSize(.large).tint(c.label) }
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 48)).foregroundStyle(c.label) }
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 48)).foregroundStyle(c.label).contentTransition(.symbolEffect(.replace)) }
}
.animation(.snappy(duration: 0.25), value: pm.playWhenReady)
.frame(width: 80, height: 80)
.contentShape(Rectangle())
}
@@ -169,15 +170,20 @@ struct NowPlayingScreen: View {
}
.frame(height: 84)
HStack {
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { panel = panel == .Mixer ? .Art : .Mixer }
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { panel = panel == .Queue ? .Art : .Queue }
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
}
.frame(height: 48)
}
.padding(.horizontal, 28)
}
/// Opens a panel, or goes back to the art if it's already open.
private func show(_ to: NpPanel) {
withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = panel == to ? .Art : to }
}
private func routeLabel(_ c: Palette) -> some View {
let eq = g.eq
let device = eq.device
@@ -209,8 +215,8 @@ struct NowPlayingScreen: View {
/// Fades a little while pressed, like Apple Music's transport.
struct PressFade: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label.opacity(configuration.isPressed ? 0.45 : 1).scaleEffect(configuration.isPressed ? 0.92 : 1)
.animation(.easeOut(duration: 0.12), value: configuration.isPressed)
configuration.label.opacity(configuration.isPressed ? 0.6 : 1).scaleEffect(configuration.isPressed ? 0.9 : 1)
.animation(configuration.isPressed ? .easeOut(duration: 0.1) : .spring(duration: 0.32, bounce: 0.45), value: configuration.isPressed)
}
}
@@ -758,37 +764,49 @@ struct MiniPlayer: View {
let pm = g.playback
if let t = pm.current?.track {
HStack(spacing: 0) {
Artwork(thumb: t.thumb, size: 46, corner: 6).padding(.trailing, 12)
Artwork(thumb: t.thumb, size: 44, corner: 8).padding(.trailing, 12)
VStack(alignment: .leading, spacing: 1) {
Text(t.title).font(.subheadline.weight(.medium)).foregroundStyle(p.label).lineLimit(1)
Text(t.title).font(.subheadline.weight(.semibold)).foregroundStyle(p.label).lineLimit(1)
Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
}
Spacer(minLength: 6)
Button(action: pm.togglePlay) {
ZStack {
if pm.buffering && pm.playWhenReady { ProgressView() }
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 24)) }
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 22)).contentTransition(.symbolEffect(.replace)) }
}
.foregroundStyle(p.label).frame(width: 48, height: 48).contentShape(Rectangle())
.animation(.snappy(duration: 0.25), value: pm.playWhenReady)
.foregroundStyle(p.label).frame(width: 44, height: 44).contentShape(Rectangle())
}
.buttonStyle(PressFade())
Button(action: pm.next) {
Image(systemName: "forward.fill").font(.system(size: 22)).foregroundStyle(p.label).frame(width: 48, height: 48).contentShape(Rectangle())
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(p.label).frame(width: 44, height: 44).contentShape(Rectangle())
}
.buttonStyle(PressFade())
}
.padding(.leading, 16).padding(.trailing, 8)
.frame(height: 64)
.background(.bar)
.overlay(alignment: .top) { p.separator.frame(height: 1 / scale) }
.padding(.leading, 8).padding(.trailing, 6)
.frame(height: 60)
.background {
// A solid card in the app's own colours instead of system glass.
let shape = RoundedRectangle(cornerRadius: 14, style: .continuous)
ZStack {
shape.fill(p.cell)
shape.fill(p.fill.opacity(p.dark ? 0.5 : 0))
shape.strokeBorder(p.separator, lineWidth: 1 / scale)
}
.shadow(color: .black.opacity(p.dark ? 0.5 : 0.12), radius: 12, y: 4)
}
.overlay(alignment: .bottom) {
// Hairline progress along the bottom edge.
// Thin progress line along the bottom of the card.
TimelineView(.animation(minimumInterval: 0.25, paused: !pm.playWhenReady)) { _ in
let d = pm.durationMs
let f = d > 0 ? min(max(Double(pm.positionMs()) / Double(d), 0), 1) : 0
GeometryReader { geo in p.accent.frame(width: geo.size.width * f, height: 2) }.frame(height: 2)
}
.padding(.horizontal, 14)
}
.padding(.horizontal, 8)
.padding(.bottom, 6)
.contentShape(Rectangle())
.onTapGesture { ui.openNowPlaying() }
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } })
+4 -4
View File
@@ -1,13 +1,13 @@
name: Stemify
options:
bundleIdPrefix: com.stemify
bundleIdPrefix: org.cdnutter
deploymentTarget:
iOS: "17.0"
settings:
base:
SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.0"
CURRENT_PROJECT_VERSION: "1"
MARKETING_VERSION: "1.0.3"
CURRENT_PROJECT_VERSION: "4"
targets:
Stemify:
type: application
@@ -15,7 +15,7 @@ targets:
sources: [Stemify]
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.stemify.mobile
PRODUCT_BUNDLE_IDENTIFIER: org.cdnutter.stemify
TARGETED_DEVICE_FAMILY: "1"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
CODE_SIGN_ENTITLEMENTS: Stemify/Stemify.entitlements
+8 -5
View File
@@ -1,5 +1,5 @@
#!/bin/bash
# Builds an unsigned Stemify.ipa on a Mac, for re-signing with Sideloadly/AltStore.
# Builds an unsigned build/stemify-<version>.ipa on a Mac, for re-signing with Sideloadly/AltStore.
# Uses swiftc directly so it works even when xcodebuild/actool need -runFirstLaunch.
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -7,14 +7,17 @@ export PATH=/opt/homebrew/bin:$PATH
OUT=build; APP=$OUT/Payload/Stemify.app
rm -rf $OUT && mkdir -p $APP
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
# Version and build number come from project.yml.
VERSION=$(sed -nE 's/^ *MARKETING_VERSION: "(.*)"/\1/p' project.yml)
BUILD=$(sed -nE 's/^ *CURRENT_PROJECT_VERSION: "(.*)"/\1/p' project.yml)
xcrun -sdk iphoneos swiftc -target arm64-apple-ios17.0 -sdk "$SDK" -O -wmo -swift-version 5 \
-module-name Stemify -parse-as-library $(find Stemify -name '*.swift') -o $APP/Stemify
# Info.plist: xcodegen's generated one with the build settings filled in.
xcodegen generate -q
sed -e 's/$(DEVELOPMENT_LANGUAGE)/en/; s/$(EXECUTABLE_NAME)/Stemify/; s/$(PRODUCT_BUNDLE_IDENTIFIER)/com.stemify.mobile/' \
-e 's/$(PRODUCT_NAME)/Stemify/; s/$(MARKETING_VERSION)/1.0/; s/$(CURRENT_PROJECT_VERSION)/1/' \
sed -e 's/$(DEVELOPMENT_LANGUAGE)/en/; s/$(EXECUTABLE_NAME)/Stemify/; s/$(PRODUCT_BUNDLE_IDENTIFIER)/org.cdnutter.stemify/' \
-e "s/\$(PRODUCT_NAME)/Stemify/; s/\$(MARKETING_VERSION)/$VERSION/; s/\$(CURRENT_PROJECT_VERSION)/$BUILD/" \
-e 's/$(PRODUCT_MODULE_NAME)/Stemify/' Stemify/Info.plist > $APP/Info.plist
P=$APP/Info.plist
plutil -insert MinimumOSVersion -string 17.0 $P
@@ -31,5 +34,5 @@ SRC=Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png
sips -z 120 120 $SRC --out $APP/AppIcon60x60@2x.png >/dev/null
sips -z 180 180 $SRC --out $APP/AppIcon60x60@3x.png >/dev/null
(cd $OUT && zip -qry Stemify.ipa Payload)
echo "$OUT/Stemify.ipa"
(cd $OUT && zip -qry stemify-$VERSION.ipa Payload)
echo "$OUT/stemify-$VERSION.ipa"
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Writes ios/source.json, an AltStore source (SideStore and Feather read it too), from the
ios-v* releases on Gitea. Only releases with a stemify-<version>.ipa are listed, since older
ones used a different bundle ID. Set GITEA_TOKEN if the repo is private."""
import json, os, re, urllib.request
REPO = "https://git.iamchrisama.com/cdnutter/stemify"
API = "https://git.iamchrisama.com/api/v1/repos/cdnutter/stemify"
RAW = f"{REPO}/raw/branch/main"
HERE = os.path.dirname(os.path.abspath(__file__))
def get(url):
req = urllib.request.Request(url)
if os.environ.get("GITEA_TOKEN"):
req.add_header("Authorization", "token " + os.environ["GITEA_TOKEN"])
with urllib.request.urlopen(req) as r:
return json.load(r)
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
m = re.search(r'MARKETING_VERSION: "(.*)"', yml), re.search(r'CURRENT_PROJECT_VERSION: "(.*)"', yml)
builds[m[0][1]] = m[1][1]
versions = []
for rel in get(API + "/releases?limit=50"):
v = rel["tag_name"].removeprefix("ios-v")
ipa = next((a for a in rel["assets"] if a["name"] == f"stemify-{v}.ipa"), None)
if not rel["tag_name"].startswith("ios-v") or not ipa:
continue
versions.append({
"version": v,
"buildVersion": builds.get(v, v),
"date": rel["published_at"],
"localizedDescription": rel["body"].strip() or f"Stemify {v}",
"downloadURL": ipa["browser_download_url"],
"size": ipa["size"],
"minOSVersion": "17.0",
})
source = {
"name": "Stemify",
"identifier": "org.cdnutter.stemify.source",
"sourceURL": f"{RAW}/ios/source.json",
"iconURL": f"{RAW}/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
"website": REPO,
"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": f"{RAW}/ios/Stemify/Resources/Assets.xcassets/AppIcon.appiconset/icon.png",
"tintColor": "#FA2D48",
"category": "entertainment",
"versions": versions,
"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": [],
}
with open(os.path.join(HERE, "..", "source.json"), "w") as f:
json.dump(source, f, indent=2)
f.write("\n")
print(f"source.json: {len(versions)} version(s)")