Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db55b5eea0 | ||
|
|
cf5f992e60 | ||
|
|
f099682a88 | ||
|
|
9415bfb50f |
No files matched your search
@@ -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).
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user