Author SHA1 Message Date
rootandClaude Opus 5.5 db55b5eea0 iOS: iPhone Duo split layout at the hinge, 1.0 RC 3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 07:57:58 -08:00
cdnutterandClaude Sonnet 5.5 cf5f992e60 stemserve: refresh index in a background thread
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 14:17:38 -08:00
rootandClaude Opus 5.5 f099682a88 ios: build-remote defaults to white-m1 host
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 09:33:45 -08:00
rootandClaude Opus 5.5 9415bfb50f iOS: source.json for 1.0 RC 2 rebuild
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 08:55:29 -08:00
8 changed files with 101 additions and 19 deletions

No files matched your search

+1
View File
@@ -28,3 +28,4 @@
2026-11-06 iOS: stem playlist covers can come from Photos (Change Cover > Choose from Photos). Stored on the phone, 1200 px JPEG.
2026-11-06 iOS: Update Download on downloaded playlists, albums, artists and stem playlists (their menus and the Downloads tab), plus Update All Downloads. Pulls the current song list, downloads new songs, drops removed ones, retries failed ones. Downloads still sync on their own when the app opens.
2026-11-06 iOS: crossfade, Off or 1-12 s in Settings > Listening. Equal-power fade, Sweet Fades skips it between songs of the same album. Seeking during a fade finishes it.
2026-11-09 iOS: iPhone Duo unfolded uses both sides of the hinge: library on one side, Now Playing docked on the other (no mini player while split). Folded and other iPhones unchanged. 1.0 RC 3 (build 23).
+10 -1
View File
@@ -51,6 +51,8 @@ final class Ui {
var nowPlayingOpen = false {
didSet { overlays.level = nowPlayingOpen ? 1 : 0 }
}
/// True while an unfolded iPhone Duo shows Now Playing docked beside the library.
private(set) var split = false
let overlays: Overlays
init(overlays: Overlays) { self.overlays = overlays }
@@ -84,7 +86,14 @@ final class Ui {
if !tabs.contains(tab) { tab = tabs.first ?? .Settings }
}
func openNowPlaying() { nowPlayingOpen = true }
/// Docked Now Playing is already on screen, so there's nothing to open while split.
func openNowPlaying() { if !split { nowPlayingOpen = true } }
func setSplit(_ on: Bool) {
guard on != split else { return }
split = on
if on { nowPlayingOpen = false }
}
/// Switches to the Library tab and pushes [r] there.
func showInLibrary(_ r: Route) {
+54 -3
View File
@@ -1,6 +1,7 @@
import SwiftUI
/// The tab bar, a navigation stack per tab, the mini player and the Now Playing sheet.
/// On an unfolded iPhone Duo the library sits on one side of the hinge and Now Playing on the other.
struct RootView: View {
@State private var ui = Ui(overlays: Overlays())
private var g: AppGraph { .shared }
@@ -8,6 +9,7 @@ struct RootView: View {
var body: some View {
let s = g.settings
let p = Palette.of(s.theme, s.accent)
DuoSplit(onSplit: { ui.setSplit($0) }) {
TabView(selection: Binding(get: { ui.tab }, set: { ui.selectTab($0) })) {
ForEach(Tab.layout(s.tabLayout), id: \.self) { t in
NavigationStack(path: ui.path(t)) {
@@ -20,6 +22,10 @@ struct RootView: View {
}
}
.modifier(GlassMiniPlayerHost())
} docked: {
NowPlayingScreen(docked: true)
}
.background(p.bg.ignoresSafeArea())
.onChange(of: s.tabLayout) { _, _ in ui.ensureValidTab() }
.fullScreenCover(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
NowPlayingScreen()
@@ -91,11 +97,12 @@ struct RootView: View {
private let glassAccessory: Bool = { if #available(iOS 26.1, *) { return true } else { return false } }()
private struct GlassMiniPlayerHost: ViewModifier {
@Environment(Ui.self) private var ui
private var g: AppGraph { .shared }
func body(content: Content) -> some View {
if #available(iOS 26.1, *) {
content
.tabViewBottomAccessory(isEnabled: g.playback.current != nil) { MiniPlayer() }
.tabViewBottomAccessory(isEnabled: g.playback.current != nil && !ui.split) { MiniPlayer() }
.tabBarMinimizeBehavior(.onScrollDown)
} else {
content
@@ -105,9 +112,10 @@ private struct GlassMiniPlayerHost: ViewModifier {
/// Floats over the screens; each screen leaves room for it so nothing ends up underneath.
private struct FloatingMiniPlayer: ViewModifier {
@Environment(Ui.self) private var ui
private var g: AppGraph { .shared }
func body(content: Content) -> some View {
if glassAccessory {
if glassAccessory || ui.split {
content
} else {
content
@@ -120,10 +128,11 @@ private struct FloatingMiniPlayer: ViewModifier {
}
private struct MiniPlayerRoom: ViewModifier {
@Environment(Ui.self) private var ui
private var g: AppGraph { .shared }
func body(content: Content) -> some View {
content.safeAreaInset(edge: .bottom, spacing: 0) {
if !glassAccessory && g.playback.current != nil { Color.clear.frame(height: 66) }
if !glassAccessory && !ui.split && g.playback.current != nil { Color.clear.frame(height: 66) }
}
}
}
@@ -131,3 +140,45 @@ private struct MiniPlayerRoom: ViewModifier {
private extension View {
func miniPlayerRoom() -> some View { modifier(MiniPlayerRoom()) }
}
/// Splits the screen at the iPhone Duo's hinge (the system's division region): [library] on the
/// leading or top side, [docked] on the other. Anywhere without an active hinge it's just [library].
/// One layout for both states keeps the library's tab and scroll state across fold and unfold.
private struct DuoSplit<Library: View, Docked: View>: View {
var onSplit: (Bool) -> Void
@ViewBuilder var library: Library
@ViewBuilder var docked: Docked
var body: some View {
GeometryReader { geo in
let cut = Self.cut(geo)
let layout = cut?.vertical == false ? AnyLayout(VStackLayout(spacing: cut?.gap ?? 0)) : AnyLayout(HStackLayout(spacing: cut?.gap ?? 0))
layout {
library
.frame(width: cut.map { $0.vertical ? $0.first : geo.size.width } ?? geo.size.width,
height: cut.map { $0.vertical ? geo.size.height : $0.first } ?? geo.size.height)
if let cut {
docked
.frame(width: cut.vertical ? cut.second : geo.size.width,
height: cut.vertical ? geo.size.height : cut.second)
}
}
.onChange(of: cut != nil, initial: true) { _, on in onSplit(on) }
}
}
/// Where the hinge cuts the view: sizes of the two sides and the gap between them.
private struct Cut { var vertical: Bool; var first: CGFloat; var gap: CGFloat; var second: CGFloat }
private static func cut(_ geo: GeometryProxy) -> Cut? {
guard #available(iOS 27.1, *),
let r = geo.reservedRegions(kind: .division).first(where: \.isActive)?.frame else { return nil }
let size = geo.size
let vertical = r.height >= r.width
let c = vertical
? Cut(vertical: true, first: r.minX, gap: r.width, second: size.width - r.maxX)
: Cut(vertical: false, first: r.minY, gap: r.height, second: size.height - r.maxY)
// A region that doesn't leave usable room on both sides isn't a hinge worth splitting at.
return c.first > 200 && c.second > 200 ? c : nil
}
}
+19 -5
View File
@@ -86,7 +86,21 @@ private extension UIColor {
// ---- now playing ----------------------------------------------------------------------------
/// Full screen, Now Playing owns the window's color scheme. Docked beside the library it only
/// sets its own pane's, since preferredColorScheme would repaint the library side too.
private struct NpScheme: ViewModifier {
var dark: Bool
var docked: Bool
func body(content: Content) -> some View {
if docked { content.environment(\.colorScheme, dark ? .dark : .light) }
else { content.preferredColorScheme(dark ? .dark : .light) }
}
}
struct NowPlayingScreen: View {
/// Shown beside the library on an unfolded iPhone Duo rather than as a full-screen cover:
/// no grabber, no drag to close, and its color scheme stays inside the pane.
var docked = false
@Environment(Ui.self) private var ui
@Environment(\.palette) private var base
@Environment(\.dismiss) private var dismiss
@@ -134,17 +148,17 @@ struct NowPlayingScreen: View {
.background(c.bg.ignoresSafeArea())
.overlay(alignment: .top) {
// Stands in for the sheet's grabber: tap or drag down to close.
Capsule().fill(c.label3).frame(width: 36, height: 5)
if !docked { Capsule().fill(c.label3).frame(width: 36, height: 5)
.frame(width: 120, height: 20).contentShape(Rectangle())
.onTapGesture { dismiss() }
.gesture(screenDrag)
.gesture(screenDrag) }
}
.offset(y: drop)
// On the art and the mixer: drag down anywhere to close, swipe up anywhere for the
// queue. The queue and lyrics scroll, so there it's the song header or the grabber.
.simultaneousGesture(screenDrag, including: panel == .Art || panel == .Mixer ? .all : .subviews)
.environment(\.palette, c)
.preferredColorScheme(c.dark ? .dark : .light)
.modifier(NpScheme(dark: c.dark, docked: docked))
.tint(c.label)
.animation(.easeInOut(duration: 0.3), value: tint)
.presentationBackground(.clear)
@@ -259,12 +273,12 @@ struct NowPlayingScreen: View {
DragGesture(minimumDistance: 20)
.onChanged { v in
let dy = v.translation.height
drop = dy > 0 && dy > abs(v.translation.width) ? dy : 0
drop = !docked && dy > 0 && dy > abs(v.translation.width) ? dy : 0
}
.onEnded { v in
let dy = v.translation.height
let vertical = abs(dy) > abs(v.translation.width)
if vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return }
if !docked && vertical && (dy > 120 || v.predictedEndTranslation.height > 300) { dismiss(); return }
withAnimation(.spring(duration: 0.3, bounce: 0.1)) { drop = 0 }
if vertical && dy < -50 { withAnimation(.spring(duration: 0.38, bounce: 0.12)) { panel = .Queue } }
}
+2 -2
View File
@@ -8,8 +8,8 @@ settings:
SWIFT_VERSION: "5.0"
# iOS needs a plain number for the bundle version; the label is what the app and releases show.
MARKETING_VERSION: "1.0"
VERSION_LABEL: "1.0 RC 2"
CURRENT_PROJECT_VERSION: "22"
VERSION_LABEL: "1.0 RC 3"
CURRENT_PROJECT_VERSION: "23"
targets:
Stemify:
type: application
+1 -1
View File
@@ -2,7 +2,7 @@
# Run from DevOps: syncs ios/ to White-M1, builds there, and pulls the IPAs back into ios/build/{public,prov}.
set -euo pipefail
cd "$(dirname "$0")/.."
H=${BUILD_HOST:-white}; R=stemify-build/ios
H=${BUILD_HOST:-white-m1}; R=stemify-build/ios
rsync -a --exclude build --exclude prov --exclude Stemify.xcodeproj ./ $H:$R/
ssh $H "cd $R && P12_PASS=\$(cat prov/cert_pass.txt 2>/dev/null) bash scripts/build-ipa.sh"
mkdir -p build/public build/prov
+3 -3
View File
@@ -19,10 +19,10 @@
"version": "1.0",
"marketingVersion": "1.0 RC 2",
"buildVersion": "22",
"date": "2026-11-06T08:41:04-08:00",
"localizedDescription": "Plain Plex songs play when Remote Access has an upload speed limit (no more 500s). Shuffle buttons work and top up as you go. Random Album Radio works like Plexamp: a shuffled album list played album by album, in track order. Recently Added has See All with the full list. Stem playlist covers can come from Photos. Crossfade (Off or 1-12 s, Settings > Listening) with Sweet Fades for same-album songs. Unsigned IPA for Sideloadly, AltStore, SideStore or Feather.",
"date": "2026-11-06T08:55:28-08:00",
"localizedDescription": "Plain Plex songs play when Remote Access has an upload speed limit (no more 500s). Shuffle buttons work and top up as you go. Random Album Radio works like Plexamp: a shuffled album list played album by album, in track order. Recently Added has See All with the full list. Stem playlist covers can come from Photos. Crossfade (Off or 1-12 s, Settings > Listening) with Sweet Fades for same-album songs. Update Download on downloaded playlists, albums and artists, plus Update All Downloads. Unsigned IPA for Sideloadly, AltStore, SideStore or Feather.",
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0-rc2/stemify-1.0-rc2.ipa",
"size": 2576564,
"size": 2579022,
"minOSVersion": "17.0"
},
{
+11 -4
View File
@@ -93,17 +93,23 @@ class Index:
self._by_key: dict[str, dict] = {}
def get(self):
if time.monotonic() - self._at > INDEX_TTL:
self.refresh()
with self._lock:
return self._body, self._etag
def entry(self, key):
if time.monotonic() - self._at > INDEX_TTL:
self.refresh()
with self._lock:
return self._by_key.get(key)
def start_background(self):
def loop():
while True:
time.sleep(INDEX_TTL)
try:
self.refresh()
except Exception as e:
log(f"index refresh failed: {e}")
threading.Thread(target=loop, daemon=True).start()
@property
def count(self):
return self._count
@@ -537,6 +543,7 @@ def main():
sys.exit(f"STEMS_DIR not found: {STEMS_DIR}")
os.makedirs(CACHE_DIR, exist_ok=True)
INDEX.refresh()
INDEX.start_background()
log(f"stemserve on :{PORT}, {INDEX.count} tracks in {STEMS_DIR}, ffmpeg={'yes' if FFMPEG else 'no'}, "
f"token={'on' if TOKEN else 'off'}")
server = QuietServer(("0.0.0.0", PORT), Handler)