Compare commits
7
Commits
ios-v1.0.4
...
ios-v1.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d52a3e257f | ||
|
|
e98d595365 | ||
|
|
41b8171df2 | ||
|
|
8e5ebb70b1 | ||
|
|
65bc69a7fe | ||
|
|
d149af336a | ||
|
|
3f7fd76762 |
No files matched your search
@@ -185,6 +185,9 @@ final class Settings: @unchecked Sendable {
|
||||
var downloadsWifiOnly: Bool { get { read(\.downloadsWifiOnly, "downloadsWifiOnly", true) } set { write(\.downloadsWifiOnly, "downloadsWifiOnly", newValue, true) } }
|
||||
/// Downloads page order: 0 download order, 1 artist, 2 release date.
|
||||
var downloadSort: Int { get { read(\.downloadSort, "downloadSort", 0) } set { write(\.downloadSort, "downloadSort", newValue, 0) } }
|
||||
var karaokeVocals: Int { get { read(\.karaokeVocals, "karaokeVocals", KaraokeVocals.Off.rawValue) } set { write(\.karaokeVocals, "karaokeVocals", newValue, KaraokeVocals.Off.rawValue) } }
|
||||
/// How far down Soft karaoke puts the vocals, in dB.
|
||||
var karaokeSoftDb: Int { get { read(\.karaokeSoftDb, "karaokeSoftDb", -12) } set { write(\.karaokeSoftDb, "karaokeSoftDb", newValue, -12) } }
|
||||
var keepStemMix: Bool { get { read(\.keepStemMix, "keepStemMix", true) } set { write(\.keepStemMix, "keepStemMix", newValue, true) } }
|
||||
var masterVolume: Float {
|
||||
get { access(keyPath: \.masterVolume); lock.lock(); defer { lock.unlock() }; return (store["masterVolume"] as? NSNumber)?.floatValue ?? 0.85 }
|
||||
|
||||
@@ -19,6 +19,8 @@ enum Stems {
|
||||
}
|
||||
}
|
||||
|
||||
enum KaraokeVocals: Int, CaseIterable { case On, Soft, Off }
|
||||
|
||||
/// One stem's fader. [level] is the slider position; 0.8 is unity gain.
|
||||
struct StemSetting: Codable, Hashable {
|
||||
static let unity: Float = 0.8
|
||||
@@ -124,7 +126,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
private(set) var message: AppMessage?
|
||||
/// Mixes saved for single songs (by rating key), put back whenever that song starts.
|
||||
private(set) var songMixes: [String: [String: StemSetting]] = [:]
|
||||
/// Karaoke mode: vocals out on every song, lyrics on Now Playing.
|
||||
/// Karaoke mode: vocals on, soft or out (per settings) on every song, lyrics on Now Playing.
|
||||
private(set) var karaoke = false
|
||||
@ObservationIgnored private var preKaraokeMix: [String: StemSetting]?
|
||||
|
||||
@@ -415,11 +417,30 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
|
||||
/// The mix a song starts with: karaoke if that's on, else the song's saved mix, else cleared (or kept, per settings).
|
||||
private func startMix(_ t: Track) -> [String: StemSetting] {
|
||||
if karaoke { return Self.karaokeMix }
|
||||
if karaoke { return karaokeMix }
|
||||
return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:])
|
||||
}
|
||||
|
||||
private static let karaokeMix = ["vocals": StemSetting(muted: true)]
|
||||
/// How much of the singer is left in karaoke mode. Soft is a guide under your own voice, as quiet as settings say.
|
||||
private var karaokeMix: [String: StemSetting] {
|
||||
switch karaokeVocals {
|
||||
case .On: return [:]
|
||||
case .Soft: return ["vocals": StemSetting(level: stemUntaper(Float(pow(10, Double(settings.karaokeSoftDb) / 20))))]
|
||||
case .Off: return ["vocals": StemSetting(muted: true)]
|
||||
}
|
||||
}
|
||||
|
||||
var karaokeVocals: KaraokeVocals { KaraokeVocals(rawValue: settings.karaokeVocals) ?? .Off }
|
||||
|
||||
func setKaraokeVocals(_ v: KaraokeVocals) {
|
||||
settings.karaokeVocals = v.rawValue
|
||||
if karaoke { applyMix(karaokeMix) }
|
||||
}
|
||||
|
||||
func setKaraokeSoftDb(_ db: Int) {
|
||||
settings.karaokeSoftDb = db
|
||||
if karaoke && karaokeVocals == .Soft { applyMix(karaokeMix) }
|
||||
}
|
||||
|
||||
/// Turning it off puts back the song's saved mix, or the one from before, unless the mix was changed meanwhile.
|
||||
func setKaraoke(_ on: Bool) {
|
||||
@@ -427,9 +448,9 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable {
|
||||
karaoke = on
|
||||
if on {
|
||||
preKaraokeMix = mix
|
||||
applyMix(Self.karaokeMix)
|
||||
applyMix(karaokeMix)
|
||||
} else {
|
||||
if MixPresets.normalized(mix) == Self.karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) }
|
||||
if MixPresets.normalized(mix) == karaokeMix { applyMix(songMix ?? preKaraokeMix ?? [:]) }
|
||||
preKaraokeMix = nil
|
||||
changed()
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ final class PlexClient: @unchecked Sendable {
|
||||
|
||||
// ---- library ------------------------------------------------------------------
|
||||
|
||||
func artists() async throws -> [Artist] {
|
||||
try await all("/library/sections/\(section)/all", [("type", 8), ("sort", "titleSort")]).map { $0.toArtist() }
|
||||
func artists(sort: String = "titleSort") async throws -> [Artist] {
|
||||
try await all("/library/sections/\(section)/all", [("type", 8), ("sort", sort)]).map { $0.toArtist() }
|
||||
}
|
||||
|
||||
func albums(sort: String = "titleSort") async throws -> [Album] {
|
||||
@@ -126,9 +126,9 @@ final class PlexClient: @unchecked Sendable {
|
||||
.metadata.map { $0.toAlbum() }
|
||||
}
|
||||
|
||||
/// One page of every track, sorted by title. Returns the page and the library's track count.
|
||||
func tracksPage(start: Int, size: Int) async throws -> ([Track], Int) {
|
||||
let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", "titleSort")], start: start, size: size)
|
||||
/// One page of every track. Returns the page and the library's track count.
|
||||
func tracksPage(start: Int, size: Int, sort: String = "titleSort") async throws -> ([Track], Int) {
|
||||
let c = try await get("/library/sections/\(section)/all", [("type", 10), ("sort", sort)], start: start, size: size)
|
||||
return (c.metadata.map { $0.toTrack() }, c.totalSize ?? c.size)
|
||||
}
|
||||
|
||||
@@ -190,8 +190,8 @@ final class PlexClient: @unchecked Sendable {
|
||||
|
||||
// ---- playlists ----------------------------------------------------------------
|
||||
|
||||
func playlists() async throws -> [Playlist] {
|
||||
try await get("/playlists", [("playlistType", "audio")]).metadata.map { $0.toPlaylist() }
|
||||
func playlists(sort: String = "titleSort") async throws -> [Playlist] {
|
||||
try await get("/playlists", [("playlistType", "audio"), ("sort", sort)]).metadata.map { $0.toPlaylist() }
|
||||
}
|
||||
|
||||
func playlistTracks(_ key: String) async throws -> [Track] {
|
||||
|
||||
@@ -14,15 +14,12 @@ struct RootView: View {
|
||||
root(t).miniPlayerRoom()
|
||||
.navigationDestination(for: Route.self) { destination($0).miniPlayerRoom() }
|
||||
}
|
||||
// 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)
|
||||
.modifier(FloatingMiniPlayer())
|
||||
.tabItem { Label(t.rawValue, systemImage: t.icon) }
|
||||
.tag(t)
|
||||
}
|
||||
}
|
||||
.modifier(GlassMiniPlayerHost())
|
||||
.sheet(isPresented: Binding(get: { ui.nowPlayingOpen }, set: { ui.nowPlayingOpen = $0 })) {
|
||||
NowPlayingScreen()
|
||||
.overlayHost(ui.overlays, level: 1)
|
||||
@@ -72,11 +69,43 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS 26.1 and up put the mini player in the tab bar's glass accessory; older ones float a card over each tab.
|
||||
private let glassAccessory: Bool = { if #available(iOS 26.1, *) { return true } else { return false } }()
|
||||
|
||||
private struct GlassMiniPlayerHost: ViewModifier {
|
||||
private var g: AppGraph { .shared }
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.1, *) {
|
||||
content
|
||||
.tabViewBottomAccessory(isEnabled: g.playback.current != nil) { MiniPlayer() }
|
||||
.tabBarMinimizeBehavior(.onScrollDown)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Floats over the screens; each screen leaves room for it so nothing ends up underneath.
|
||||
private struct FloatingMiniPlayer: ViewModifier {
|
||||
private var g: AppGraph { .shared }
|
||||
func body(content: Content) -> some View {
|
||||
if glassAccessory {
|
||||
content
|
||||
} else {
|
||||
content
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
if !glassAccessory && g.playback.current != nil { Color.clear.frame(height: 66) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,9 +111,131 @@ private struct LibraryRow: View {
|
||||
|
||||
// ---- artists --------------------------------------------------------------------------------
|
||||
|
||||
// ---- sorting ---------------------------------------------------------------------------------
|
||||
|
||||
/// One way to sort a list. `key` is Plex's sort key; `desc` is the direction it starts in.
|
||||
struct SortOption {
|
||||
let title: String
|
||||
let key: String
|
||||
var desc = false
|
||||
var up = "Ascending"
|
||||
var down = "Descending"
|
||||
|
||||
/// Plex reverses a compound key by its first field.
|
||||
func param(_ reversed: Bool) -> String {
|
||||
guard desc != reversed else { return key }
|
||||
var fields = key.split(separator: ",").map(String.init)
|
||||
fields[0] += ":desc"
|
||||
return fields.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
private let AZ = (up: "A to Z", down: "Z to A")
|
||||
private let NEW = (up: "Oldest First", down: "Newest First")
|
||||
private let MOST = (up: "Fewest First", down: "Most First")
|
||||
|
||||
let ARTIST_SORTS = [
|
||||
SortOption(title: "Name", key: "titleSort", up: AZ.up, down: AZ.down),
|
||||
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
|
||||
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
|
||||
]
|
||||
|
||||
let ALBUM_SORTS = [
|
||||
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
|
||||
SortOption(title: "Artist", key: "artist.titleSort,year", up: AZ.up, down: AZ.down),
|
||||
SortOption(title: "Year", key: "year", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Release Date", key: "originallyAvailableAt", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
|
||||
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
|
||||
]
|
||||
|
||||
let SONG_SORTS = [
|
||||
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
|
||||
SortOption(title: "Artist", key: "artist.titleSort,album.titleSort,album.index,album.id,track.absoluteIndex", up: AZ.up, down: AZ.down),
|
||||
SortOption(title: "Album", key: "album.titleSort,album.index,album.id,track.absoluteIndex", up: AZ.up, down: AZ.down),
|
||||
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
|
||||
SortOption(title: "Rating", key: "userRating", desc: true, up: "Lowest First", down: "Highest First"),
|
||||
SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"),
|
||||
]
|
||||
|
||||
let PLAYLIST_SORTS = [
|
||||
SortOption(title: "Title", key: "titleSort", up: AZ.up, down: AZ.down),
|
||||
SortOption(title: "Date Added", key: "addedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Last Updated", key: "updatedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Last Played", key: "lastViewedAt", desc: true, up: NEW.up, down: NEW.down),
|
||||
SortOption(title: "Plays", key: "viewCount", desc: true, up: MOST.up, down: MOST.down),
|
||||
SortOption(title: "Duration", key: "duration", desc: true, up: "Shortest First", down: "Longest First"),
|
||||
]
|
||||
|
||||
/// A screen's sort choice, kept across launches under `sort.<name>`.
|
||||
struct SortState: DynamicProperty {
|
||||
let options: [SortOption]
|
||||
@AppStorage private var index: Int
|
||||
@AppStorage private var reversed: Bool
|
||||
|
||||
init(_ name: String, _ options: [SortOption]) {
|
||||
self.options = options
|
||||
_index = AppStorage(wrappedValue: 0, "sort.\(name)")
|
||||
_reversed = AppStorage(wrappedValue: false, "sort.\(name).rev")
|
||||
}
|
||||
|
||||
var current: Int { options.indices.contains(index) ? index : 0 }
|
||||
var option: SortOption { options[current] }
|
||||
var param: String { option.param(reversed) }
|
||||
/// The default order (first option, its own direction), where a letter index makes sense.
|
||||
var isDefault: Bool { current == 0 && !reversed }
|
||||
|
||||
/// Picking the current field again flips it, like Plexamp.
|
||||
func pick(_ i: Int) {
|
||||
if i == current { reversed.toggle() } else { index = i; reversed = false }
|
||||
}
|
||||
func setDescending(_ d: Bool) { reversed = d != option.desc }
|
||||
var descending: Bool { option.desc != reversed }
|
||||
}
|
||||
|
||||
/// Toolbar menu: the sort fields, then the direction for the chosen one.
|
||||
struct SortMenu: View {
|
||||
let sort: SortState
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Section("Sort By") {
|
||||
ForEach(sort.options.indices, id: \.self) { i in
|
||||
Button { sort.pick(i) } label: {
|
||||
if i == sort.current {
|
||||
Label(sort.options[i].title, systemImage: sort.descending ? "chevron.down" : "chevron.up")
|
||||
} else {
|
||||
Text(sort.options[i].title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Button { sort.setDescending(false) } label: {
|
||||
if sort.descending { Text(sort.option.up) } else { Label(sort.option.up, systemImage: "checkmark") }
|
||||
}
|
||||
Button { sort.setDescending(true) } label: {
|
||||
if sort.descending { Label(sort.option.down, systemImage: "checkmark") } else { Text(sort.option.down) }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- artists ---------------------------------------------------------------------------------
|
||||
|
||||
struct ArtistsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
private let sort = SortState("artists", ARTIST_SORTS)
|
||||
@State private var artists: LoadState<[Artist]> = .loading
|
||||
@State private var reload = 0
|
||||
|
||||
@@ -135,14 +257,15 @@ struct ArtistsScreen: View {
|
||||
}
|
||||
.background(p.bg)
|
||||
.overlay(alignment: .trailing) {
|
||||
if let list = artists.value, list.count > 30 {
|
||||
if sort.isDefault, let list = artists.value, list.count > 30 {
|
||||
SectionIndex(titles: list.map(\.sortTitle)) { i in proxy.scrollTo(list[i].ratingKey, anchor: .top) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Artists")
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||
await LoadState.load($artists) { try await AppGraph.shared.plex.artists() }
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") {
|
||||
await LoadState.load($artists) { try await AppGraph.shared.plex.artists(sort: sort.param) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,11 +318,9 @@ private struct SectionIndex: View {
|
||||
|
||||
// ---- albums ---------------------------------------------------------------------------------
|
||||
|
||||
private let ALBUM_SORTS = [("Title", "titleSort"), ("Artist", "artist.titleSort,year"), ("Recently Added", "addedAt:desc"), ("Year", "year:desc")]
|
||||
|
||||
struct AlbumsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@State private var sort = 0
|
||||
private let sort = SortState("albums", ALBUM_SORTS)
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var reload = 0
|
||||
|
||||
@@ -210,16 +331,10 @@ struct AlbumsScreen: View {
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Sort") {
|
||||
ui.overlays.actions("Sort By", ALBUM_SORTS.enumerated().map { i, s in
|
||||
SheetAction(i == sort ? "✓ \(s.0)" : s.0) { sort = i }
|
||||
})
|
||||
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: ALBUM_SORTS[sort].1) }
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(sort.param)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: sort.param) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,6 +348,9 @@ struct SongsScreen: View {
|
||||
@State private var total = -1
|
||||
@State private var loading = false
|
||||
@State private var error: String?
|
||||
/// Bumped when the sort changes, so a page still loading for the old order is dropped.
|
||||
@State private var gen = 0
|
||||
private let sort = SortState("songs", SONG_SORTS)
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
/// Shuffles the whole library, not just the pages loaded so far.
|
||||
@@ -264,17 +382,25 @@ struct SongsScreen: View {
|
||||
if loading { LoadingRow() }
|
||||
if let error { ErrorRow(message: error) { loadMore() } }
|
||||
}
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||
.task { if tracks.isEmpty { loadMore() } }
|
||||
.onChange(of: sort.param) {
|
||||
gen += 1; tracks = []; total = -1; error = nil; loading = false
|
||||
loadMore()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() {
|
||||
if loading || (total >= 0 && tracks.count >= total) { return }
|
||||
loading = true
|
||||
let mine = gen, order = sort.param
|
||||
Task {
|
||||
do {
|
||||
let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200)
|
||||
let (page, n) = try await g.plex.tracksPage(start: tracks.count, size: 200, sort: order)
|
||||
guard mine == gen else { return }
|
||||
tracks += page; total = n; error = nil
|
||||
} catch {
|
||||
guard mine == gen else { return }
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
loading = false
|
||||
|
||||
@@ -183,7 +183,7 @@ struct NowPlayingScreen: View {
|
||||
}
|
||||
|
||||
/// Opens a panel, or goes back to the art if it's already open.
|
||||
/// Karaoke: vocals out and lyrics up. If it's on but another panel is showing, brings the lyrics back first.
|
||||
/// Karaoke: vocals down and lyrics up. If it's on but another panel is showing, brings the lyrics back first.
|
||||
private func karaoke() {
|
||||
let pm = g.playback
|
||||
withAnimation(.spring(duration: 0.38, bounce: 0.12)) {
|
||||
@@ -794,8 +794,14 @@ struct MiniPlayer: View {
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
if let t = g.playback.current?.track {
|
||||
if #available(iOS 26.1, *) { GlassMiniPlayer(track: t) } else { card(t) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Before iOS 26: a card floating over each tab.
|
||||
@ViewBuilder private func card(_ t: Track) -> some View {
|
||||
let pm = g.playback
|
||||
if let t = pm.current?.track {
|
||||
HStack(spacing: 0) {
|
||||
Artwork(thumb: t.thumb, size: 44, corner: 8).padding(.trailing, 12)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
@@ -803,19 +809,7 @@ struct MiniPlayer: View {
|
||||
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: 22)).contentTransition(.symbolEffect(.replace)) }
|
||||
}
|
||||
.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: 20)).foregroundStyle(p.label).frame(width: 44, height: 44).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
MiniTransport(color: p.label, next: true)
|
||||
}
|
||||
.padding(.leading, 8).padding(.trailing, 6)
|
||||
.frame(height: 60)
|
||||
@@ -845,6 +839,56 @@ struct MiniPlayer: View {
|
||||
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } })
|
||||
.actionsMenu(ui.menu.track(t))
|
||||
}
|
||||
}
|
||||
|
||||
/// iOS 26: lives in the tab bar's Liquid Glass accessory, like Music. Shrinks to title and play when the bar collapses.
|
||||
@available(iOS 26.1, *)
|
||||
private struct GlassMiniPlayer: View {
|
||||
let track: Track
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.tabViewBottomAccessoryPlacement) private var placement
|
||||
private var inline: Bool { placement == .inline }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Artwork(thumb: track.thumb, size: inline ? 28 : 34, corner: 6)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(track.title).font(.subheadline.weight(.semibold)).lineLimit(1)
|
||||
if !inline { Text(track.artist).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
|
||||
}
|
||||
Spacer(minLength: 4)
|
||||
MiniTransport(color: .primary, next: !inline)
|
||||
}
|
||||
.padding(.leading, 10).padding(.trailing, 6)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { ui.openNowPlaying() }
|
||||
.actionsMenu(ui.menu.track(track))
|
||||
}
|
||||
}
|
||||
|
||||
/// Play/pause (spinner while buffering) and next, for the mini player.
|
||||
private struct MiniTransport: View {
|
||||
let color: Color
|
||||
let next: Bool
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
let pm = g.playback
|
||||
Button(action: pm.togglePlay) {
|
||||
ZStack {
|
||||
if pm.buffering && pm.playWhenReady { ProgressView() }
|
||||
else { Image(systemName: pm.playWhenReady ? "pause.fill" : "play.fill").font(.system(size: 22)).contentTransition(.symbolEffect(.replace)) }
|
||||
}
|
||||
.animation(.snappy(duration: 0.25), value: pm.playWhenReady)
|
||||
.foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
if next {
|
||||
Button(action: pm.next) {
|
||||
Image(systemName: "forward.fill").font(.system(size: 20)).foregroundStyle(color).frame(width: 44, height: 44).contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(PressFade())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,6 +902,22 @@ private struct LyricsPanel: View {
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Soft leaves the singer in quietly as a guide.
|
||||
HStack(spacing: 12) {
|
||||
Text("Vocals").font(.subheadline.weight(.semibold)).foregroundStyle(p.label2)
|
||||
Picker("Vocals", selection: Binding(get: { g.playback.karaokeVocals }, set: { g.playback.setKaraokeVocals($0) })) {
|
||||
ForEach(KaraokeVocals.allCases, id: \.self) { Text(String(describing: $0)).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.horizontal, 28).padding(.top, 8)
|
||||
content
|
||||
}
|
||||
.task(id: track.ratingKey) { await follow() }
|
||||
}
|
||||
|
||||
@ViewBuilder private var content: some View {
|
||||
Group {
|
||||
if loading {
|
||||
ProgressView()
|
||||
@@ -869,7 +929,9 @@ private struct LyricsPanel: View {
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.task(id: track.ratingKey) {
|
||||
}
|
||||
|
||||
private func follow() async {
|
||||
loading = true
|
||||
at = -1
|
||||
lyrics = await LyricsSource.shared.lyrics(track)
|
||||
@@ -883,7 +945,6 @@ private struct LyricsPanel: View {
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func lines(_ l: Lyrics) -> some View {
|
||||
ScrollViewReader { proxy in
|
||||
|
||||
@@ -6,6 +6,7 @@ struct PlaylistsScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@State private var lists: LoadState<[Playlist]> = .loading
|
||||
@State private var reload = 0
|
||||
private let sort = SortState("playlists", PLAYLIST_SORTS)
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
@@ -21,12 +22,13 @@ struct PlaylistsScreen: View {
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) }
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button { newPlaylist() } label: { Image(systemName: "plus") }
|
||||
}
|
||||
}
|
||||
.task(id: "\(g.settings.musicSection)|\(reload)") {
|
||||
await LoadState.load($lists) { try await g.plex.playlists() }
|
||||
.task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") {
|
||||
await LoadState.load($lists) { try await g.plex.playlists(sort: sort.param) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,32 @@ private struct ToggleCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// How quiet karaoke's Soft vocals are.
|
||||
private struct SoftVocalsCell: View {
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
var body: some View {
|
||||
let db = g.settings.karaokeSoftDb
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "music.mic", bg: 0xFA2D48)
|
||||
Text("Karaoke Soft Vocals").foregroundStyle(p.label)
|
||||
Spacer()
|
||||
Text("\(db) dB").monospacedDigit().foregroundStyle(p.label2)
|
||||
}
|
||||
Slider(value: Binding(get: { Double(db) }, set: { g.playback.setKaraokeSoftDb(Int($0.rounded())) }), in: -30...(-3), step: 1) {
|
||||
Text("Karaoke Soft Vocals")
|
||||
} minimumValueLabel: {
|
||||
Text("Quieter").font(.caption).foregroundStyle(p.label2)
|
||||
} maximumValueLabel: {
|
||||
Text("Louder").font(.caption).foregroundStyle(p.label2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.themedRow()
|
||||
}
|
||||
}
|
||||
|
||||
/// A tappable row in the accent (or red), optionally centred.
|
||||
private struct ActionCell: View {
|
||||
let title: String
|
||||
@@ -115,6 +141,7 @@ struct SettingsScreen: View {
|
||||
NavCell(title: "Equalizer", value: !g.eq.state.enabled ? "Off" : g.eq.active?.name ?? "On", icon: "slider.vertical.3", iconBg: 0x34C759, route: .Eq)
|
||||
NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality)
|
||||
ToggleCell(title: "Keep Stem Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
|
||||
SoftVocalsCell()
|
||||
} header: { Header(text: "Sound") }
|
||||
Section {
|
||||
NavCell(title: "Cache & Downloads", value: gb(g.cache.usage.cachedBytes), icon: "externaldrive.fill", iconBg: 0x8E8E93, route: .Storage)
|
||||
|
||||
+2
-2
@@ -6,8 +6,8 @@ options:
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.0"
|
||||
MARKETING_VERSION: "1.0.4"
|
||||
CURRENT_PROJECT_VERSION: "5"
|
||||
MARKETING_VERSION: "1.0.7"
|
||||
CURRENT_PROJECT_VERSION: "8"
|
||||
targets:
|
||||
Stemify:
|
||||
type: application
|
||||
|
||||
@@ -19,7 +19,13 @@ def get(url):
|
||||
|
||||
|
||||
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
|
||||
# Build numbers: older ones from the last source.json, the current one from project.yml.
|
||||
builds = {}
|
||||
try:
|
||||
for v in json.load(open(os.path.join(HERE, "..", "source.json")))["apps"][0]["versions"]:
|
||||
builds[v["version"]] = v["buildVersion"]
|
||||
except (OSError, ValueError, KeyError, IndexError):
|
||||
pass
|
||||
m = re.search(r'MARKETING_VERSION: "(.*)"', yml), re.search(r'CURRENT_PROJECT_VERSION: "(.*)"', yml)
|
||||
builds[m[0][1]] = m[1][1]
|
||||
|
||||
|
||||
@@ -15,6 +15,33 @@
|
||||
"tintColor": "#FA2D48",
|
||||
"category": "entertainment",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.6",
|
||||
"buildVersion": "7",
|
||||
"date": "2026-10-20T09:31:01-07:00",
|
||||
"localizedDescription": "- Karaoke vocals option on the lyrics panel: On (full vocals), Soft (vocals about 12 dB down as a guide), Off (no vocals). Remembered between songs and launches.\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
|
||||
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.6/stemify-1.0.6.ipa",
|
||||
"size": 1375443,
|
||||
"minOSVersion": "17.0"
|
||||
},
|
||||
{
|
||||
"version": "1.0.5",
|
||||
"buildVersion": "6",
|
||||
"date": "2026-10-20T09:23:39-07:00",
|
||||
"localizedDescription": "- Sort menu on Artists, Albums, Songs and Playlists (Name, Date Added, Last Played, Plays, Rating, Year, Duration and more). Tap the current field again to flip direction (A to Z / Z to A, newest / oldest).\n- Each screen remembers its sort.\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
|
||||
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.5/stemify-1.0.5.ipa",
|
||||
"size": 1370686,
|
||||
"minOSVersion": "17.0"
|
||||
},
|
||||
{
|
||||
"version": "1.0.4",
|
||||
"buildVersion": "5",
|
||||
"date": "2026-10-20T09:18:33-07:00",
|
||||
"localizedDescription": "Save a stem mix for a song (Save chip in the mixer); it comes back every time that song plays. New mic button for karaoke mode: vocals muted on every song plus synced lyrics from LRCLIB, tap a line to jump to it.",
|
||||
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.4/stemify-1.0.4.ipa",
|
||||
"size": 1355610,
|
||||
"minOSVersion": "17.0"
|
||||
},
|
||||
{
|
||||
"version": "1.0.3",
|
||||
"buildVersion": "4",
|
||||
|
||||
Reference in new issue
Block a user