1 Commits
Author SHA1 Message Date
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
5 changed files with 61 additions and 24 deletions

No files matched your search

+3
View File
@@ -298,6 +298,9 @@ final class StemEngine: @unchecked Sendable {
session = s session = s
wantPlay = play wantPlay = play
resetOutput(s) 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() publish()
} }
} }
+19 -3
View File
@@ -11,11 +11,14 @@ struct RootView: View {
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.allCases, id: \.self) { t in
NavigationStack(path: ui.path(t)) { 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) { // Floats over the screens; each screen leaves room for it so nothing ends up underneath.
if g.playback.current != nil { MiniPlayer() } .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) } .tabItem { Label(t.rawValue, systemImage: t.icon) }
.tag(t) .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()) }
}
+36 -18
View File
@@ -108,8 +108,7 @@ struct NowPlayingScreen: View {
} }
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.transition(.opacity) .transition(.opacity.combined(with: .scale(scale: 0.96)))
.animation(.easeInOut(duration: 0.18), value: panel)
controls(track) controls(track)
} 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(maxHeight: .infinity)
@@ -147,9 +146,10 @@ struct NowPlayingScreen: View {
EllipsisButton(items: ui.menu.track(track)) EllipsisButton(items: ui.menu.track(track))
} }
.padding(.top, 14) .padding(.top, 14)
.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, 18) .padding(.top, panel == .Art ? 34 : 18)
HStack { HStack {
Spacer() Spacer()
TransportButton(symbol: "backward.fill", size: 34, action: pm.previous) TransportButton(symbol: "backward.fill", size: 34, action: pm.previous)
@@ -157,8 +157,9 @@ struct NowPlayingScreen: View {
Button(action: pm.togglePlay) { Button(action: pm.togglePlay) {
ZStack { ZStack {
if pm.buffering && pm.playWhenReady { ProgressView().controlSize(.large).tint(c.label) } 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) .frame(width: 80, height: 80)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@@ -169,15 +170,20 @@ struct NowPlayingScreen: View {
} }
.frame(height: 84) .frame(height: 84)
HStack { 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) 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) .frame(height: 48)
} }
.padding(.horizontal, 28) .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 { private func routeLabel(_ c: Palette) -> some View {
let eq = g.eq let eq = g.eq
let device = eq.device let device = eq.device
@@ -209,8 +215,8 @@ struct NowPlayingScreen: View {
/// Fades a little while pressed, like Apple Music's transport. /// Fades a little while pressed, like Apple Music's transport.
struct PressFade: ButtonStyle { struct PressFade: ButtonStyle {
func makeBody(configuration: Configuration) -> some View { func makeBody(configuration: Configuration) -> some View {
configuration.label.opacity(configuration.isPressed ? 0.45 : 1).scaleEffect(configuration.isPressed ? 0.92 : 1) configuration.label.opacity(configuration.isPressed ? 0.6 : 1).scaleEffect(configuration.isPressed ? 0.9 : 1)
.animation(.easeOut(duration: 0.12), value: configuration.isPressed) .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 let pm = g.playback
if let t = pm.current?.track { if let t = pm.current?.track {
HStack(spacing: 0) { 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) { 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) Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
} }
Spacer(minLength: 6) Spacer(minLength: 6)
Button(action: pm.togglePlay) { Button(action: pm.togglePlay) {
ZStack { ZStack {
if pm.buffering && pm.playWhenReady { ProgressView() } 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()) .buttonStyle(PressFade())
Button(action: pm.next) { 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()) .buttonStyle(PressFade())
} }
.padding(.leading, 16).padding(.trailing, 8) .padding(.leading, 8).padding(.trailing, 6)
.frame(height: 64) .frame(height: 60)
.background(.bar) .background {
.overlay(alignment: .top) { p.separator.frame(height: 1 / scale) } // 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) { .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 TimelineView(.animation(minimumInterval: 0.25, paused: !pm.playWhenReady)) { _ in
let d = pm.durationMs let d = pm.durationMs
let f = d > 0 ? min(max(Double(pm.positionMs()) / Double(d), 0), 1) : 0 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) 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()) .contentShape(Rectangle())
.onTapGesture { ui.openNowPlaying() } .onTapGesture { ui.openNowPlaying() }
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } }) .gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } })
+2 -2
View File
@@ -6,8 +6,8 @@ options:
settings: settings:
base: base:
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.0" MARKETING_VERSION: "1.0.1"
CURRENT_PROJECT_VERSION: "1" CURRENT_PROJECT_VERSION: "2"
targets: targets:
Stemify: Stemify:
type: application type: application
+1 -1
View File
@@ -14,7 +14,7 @@ xcrun -sdk iphoneos swiftc -target arm64-apple-ios17.0 -sdk "$SDK" -O -wmo -swif
# Info.plist: xcodegen's generated one with the build settings filled in. # Info.plist: xcodegen's generated one with the build settings filled in.
xcodegen generate -q xcodegen generate -q
sed -e 's/$(DEVELOPMENT_LANGUAGE)/en/; s/$(EXECUTABLE_NAME)/Stemify/; s/$(PRODUCT_BUNDLE_IDENTIFIER)/com.stemify.mobile/' \ 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/' \ -e 's/$(PRODUCT_NAME)/Stemify/; s/$(MARKETING_VERSION)/1.0.1/; s/$(CURRENT_PROJECT_VERSION)/2/' \
-e 's/$(PRODUCT_MODULE_NAME)/Stemify/' Stemify/Info.plist > $APP/Info.plist -e 's/$(PRODUCT_MODULE_NAME)/Stemify/' Stemify/Info.plist > $APP/Info.plist
P=$APP/Info.plist P=$APP/Info.plist
plutil -insert MinimumOSVersion -string 17.0 $P plutil -insert MinimumOSVersion -string 17.0 $P