diff --git a/ios/Stemify/Engine/StemEngine.swift b/ios/Stemify/Engine/StemEngine.swift index 5b50ca6..79d2db8 100644 --- a/ios/Stemify/Engine/StemEngine.swift +++ b/ios/Stemify/Engine/StemEngine.swift @@ -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() } } diff --git a/ios/Stemify/UI/RootView.swift b/ios/Stemify/UI/RootView.swift index ab73598..8fb6524 100644 --- a/ios/Stemify/UI/RootView.swift +++ b/ios/Stemify/UI/RootView.swift @@ -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()) } +} diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift index 3688a91..e75aff3 100644 --- a/ios/Stemify/UI/Screens/NowPlayingScreen.swift +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -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() } }) diff --git a/ios/project.yml b/ios/project.yml index e156d24..b851bd5 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -6,8 +6,8 @@ options: settings: base: SWIFT_VERSION: "5.0" - MARKETING_VERSION: "1.0" - CURRENT_PROJECT_VERSION: "1" + MARKETING_VERSION: "1.0.1" + CURRENT_PROJECT_VERSION: "2" targets: Stemify: type: application diff --git a/ios/scripts/build-ipa.sh b/ios/scripts/build-ipa.sh index b95a110..8dedc03 100755 --- a/ios/scripts/build-ipa.sh +++ b/ios/scripts/build-ipa.sh @@ -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. 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/' \ + -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 P=$APP/Info.plist plutil -insert MinimumOSVersion -string 17.0 $P