Compare commits
2
Commits
ios-v1.0.8
...
ios-v1.0.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb541b9a16 | ||
|
|
790f8a35a2 |
No files matched your search
@@ -0,0 +1,202 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// One sheet for adding songs to a playlist: Plex or stem, new or existing, and for stem playlists the mix.
|
||||||
|
/// Replaces the chain of dialogs, each of which had to wait for the last to go away.
|
||||||
|
struct AddToPlaylistSheet: View {
|
||||||
|
let req: Overlays.AddToPlaylist
|
||||||
|
let overlays: Overlays
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
@AppStorage("addPlaylistKind") private var kind = PlaylistKind.Plex
|
||||||
|
@State private var plex: LoadState<[Playlist]> = .loading
|
||||||
|
@State private var naming = false
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var mixIndex = 0
|
||||||
|
@State private var busy: String?
|
||||||
|
@FocusState private var nameFocused: Bool
|
||||||
|
private var g: AppGraph { .shared }
|
||||||
|
|
||||||
|
private struct MixOption { var label: String; var mix: [String: StemSetting] }
|
||||||
|
|
||||||
|
/// What's playing now and the song's saved mix (for a single song), then the presets.
|
||||||
|
private var mixes: [MixOption] {
|
||||||
|
let pm = g.playback
|
||||||
|
var o: [MixOption] = []
|
||||||
|
if req.tracks.count == 1, let t = req.tracks.first {
|
||||||
|
if pm.current?.track.ratingKey == t.ratingKey { o.append(MixOption(label: "Now: \(StemPlaylists.label(pm.mix))", mix: pm.mix)) }
|
||||||
|
if let m = pm.songMixes[t.ratingKey] { o.append(MixOption(label: "Saved: \(StemPlaylists.label(m))", mix: m)) }
|
||||||
|
}
|
||||||
|
for preset in MixPresets.all {
|
||||||
|
if let m = preset.build(StemPlaylists.allStems) { o.append(MixOption(label: StemPlaylists.label(m), mix: m)) }
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
private var plexKeys: [String] { req.tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") } }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
songHeader
|
||||||
|
Picker("Kind", selection: $kind) {
|
||||||
|
Text("Plex").tag(PlaylistKind.Plex)
|
||||||
|
Text("Stem").tag(PlaylistKind.Stem)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||||
|
}
|
||||||
|
if kind == .Stem {
|
||||||
|
Section("Mix") { mixChips }
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
newRow
|
||||||
|
if kind == .Plex { plexRows } else { stemRows }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Add to Playlist")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } }
|
||||||
|
.animation(.default, value: kind)
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
.task {
|
||||||
|
let plexClient = g.plex
|
||||||
|
let skip = req.skip
|
||||||
|
await LoadState.load($plex) { try await plexClient.playlists().filter { !$0.smart && $0.ratingKey != skip } }
|
||||||
|
}
|
||||||
|
.onChange(of: kind) { _, _ in naming = false; name = "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var songHeader: some View {
|
||||||
|
let t = req.tracks.first
|
||||||
|
return HStack(spacing: 12) {
|
||||||
|
Artwork(thumb: t?.thumb, size: 48, corner: 6, placeholder: "music.note")
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(req.tracks.count == 1 ? t?.title ?? "" : songs(req.tracks.count)).foregroundStyle(p.label).lineLimit(1)
|
||||||
|
Text(req.tracks.count == 1 ? t?.artist ?? "" : req.title).font(.subheadline).foregroundStyle(p.label2).lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mixChips: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(Array(mixes.enumerated()), id: \.offset) { i, o in
|
||||||
|
let on = i == mixIndex
|
||||||
|
Button { mixIndex = i } label: {
|
||||||
|
Text(o.label).font(.subheadline.weight(.medium))
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 7)
|
||||||
|
.foregroundStyle(on ? Color.white : p.label)
|
||||||
|
.background(on ? p.accent : p.fill, in: Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var newRow: some View {
|
||||||
|
if naming {
|
||||||
|
HStack {
|
||||||
|
TextField(kind == .Plex ? "Playlist name" : "Stem playlist name", text: $name)
|
||||||
|
.focused($nameFocused).submitLabel(.done).onSubmit(create)
|
||||||
|
Button("Create", action: create).bold()
|
||||||
|
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || busy != nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
naming = true
|
||||||
|
nameFocused = true
|
||||||
|
} label: {
|
||||||
|
Label(kind == .Plex ? "New Playlist" : "New Stem Playlist", systemImage: "plus").foregroundStyle(p.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var plexRows: some View {
|
||||||
|
if plexKeys.isEmpty {
|
||||||
|
Text("These songs aren't in Plex. Use a stem playlist instead.").foregroundStyle(p.label2)
|
||||||
|
} else {
|
||||||
|
switch plex {
|
||||||
|
case .loading: HStack { Spacer(); ProgressView(); Spacer() }
|
||||||
|
case .err(let e): Text("Couldn't load playlists: \(e)").foregroundStyle(p.label2)
|
||||||
|
case .ok(let lists):
|
||||||
|
ForEach(lists) { pl in
|
||||||
|
row(pl.title, pl.thumb, songs(pl.trackCount), "music.note.list", busy: busy == pl.ratingKey) { addPlex(pl) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private var stemRows: some View {
|
||||||
|
ForEach(g.stemPlaylists.lists) { pl in
|
||||||
|
row(pl.title, pl.thumb, songs(pl.items.count), "slider.horizontal.3", busy: false) {
|
||||||
|
g.stemPlaylists.add(pl.id, tracks: req.tracks, mix: mixes[mixIndex].mix)
|
||||||
|
done("Added to \(pl.title)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ title: String, _ thumb: String?, _ sub: String, _ placeholder: String, busy: Bool, _ tap: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: tap) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Artwork(thumb: thumb, size: 44, corner: 5, placeholder: placeholder)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(title).foregroundStyle(p.label).lineLimit(1)
|
||||||
|
Text(sub).font(.caption).foregroundStyle(p.label2)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if busy { ProgressView() }
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(self.busy != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func create() {
|
||||||
|
let n = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if n.isEmpty || busy != nil { return }
|
||||||
|
if kind == .Stem {
|
||||||
|
g.stemPlaylists.create(n, tracks: req.tracks, mix: mixes[mixIndex].mix)
|
||||||
|
done("Created “\(n)”")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy = "new"
|
||||||
|
let plexClient = g.plex, keys = plexKeys
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
_ = try await plexClient.createPlaylist(n, trackKeys: keys)
|
||||||
|
done("Created “\(n)”")
|
||||||
|
} catch {
|
||||||
|
busy = nil
|
||||||
|
overlays.hud("Couldn't create: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addPlex(_ pl: Playlist) {
|
||||||
|
busy = pl.ratingKey
|
||||||
|
let plexClient = g.plex, keys = plexKeys
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await plexClient.addToPlaylist(pl.ratingKey, trackKeys: keys)
|
||||||
|
done("Added to \(pl.title)")
|
||||||
|
} catch {
|
||||||
|
busy = nil
|
||||||
|
overlays.hud("Couldn't add: \(error.localizedDescription)", "exclamationmark.triangle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func done(_ msg: String) {
|
||||||
|
dismiss()
|
||||||
|
overlays.hud(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ struct MediaMenu {
|
|||||||
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) { g.downloads.remove([t]) })
|
a.append(SheetAction("Remove Download", icon: "trash", destructive: true) { g.downloads.remove([t]) })
|
||||||
}
|
}
|
||||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { addToPlaylist([t]) })
|
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { addToPlaylist([t]) })
|
||||||
a.append(SheetAction("Add to Stem Playlist…", icon: "slider.horizontal.3") { addToStemPlaylist([t]) })
|
|
||||||
if t.partFile != nil, g.stems.index.size > 0, g.stems.find(t) == nil { a.append(stemNow { [t] }) }
|
if t.partFile != nil, g.stems.index.size > 0, g.stems.find(t) == nil { a.append(stemNow { [t] }) }
|
||||||
if allowGoTo, let k = t.albumKey {
|
if allowGoTo, let k = t.albumKey {
|
||||||
a.append(SheetAction("Go to Album", icon: "square.stack") {
|
a.append(SheetAction("Go to Album", icon: "square.stack") {
|
||||||
@@ -129,8 +128,7 @@ struct MediaMenu {
|
|||||||
overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(downloaded) }
|
overlays.confirm("Remove Download?", "“\(title)” will no longer be available offline.", "Remove") { g.downloads.remove(downloaded) }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey) } })
|
a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey, title: title) } })
|
||||||
a.append(SheetAction("Add to Stem Playlist…", icon: "slider.horizontal.3") { withTracks(load) { addToStemPlaylist($0) } })
|
|
||||||
if !stemmed { a.append(stemNow(load)) }
|
if !stemmed { a.append(stemNow(load)) }
|
||||||
return a + extra
|
return a + extra
|
||||||
}
|
}
|
||||||
@@ -173,32 +171,9 @@ struct MediaMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func addToPlaylist(_ tracks: [Track], skip: String? = nil) {
|
/// Plex or stem playlist, picked in one sheet.
|
||||||
Task {
|
func addToPlaylist(_ tracks: [Track], skip: String? = nil, title: String = "") {
|
||||||
let lists: [Playlist]
|
overlays.addToPlaylist(tracks, title: title, skip: skip)
|
||||||
do { lists = try await g.plex.playlists().filter { !$0.smart && $0.ratingKey != skip } } catch {
|
|
||||||
overlays.hud("Couldn't load playlists", "exclamationmark.triangle")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let keys = tracks.map(\.ratingKey).filter { !$0.hasPrefix("stem:") }
|
|
||||||
if keys.isEmpty {
|
|
||||||
overlays.hud("These songs aren't in Plex, so they can't go in a playlist", "info.circle")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let plex = g.plex
|
|
||||||
overlays.actions("Add \(songs(keys.count)) to Playlist", [SheetAction("New Playlist…") { newPlaylist(tracks) }] + lists.map { pl in
|
|
||||||
SheetAction(pl.title) {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await plex.addToPlaylist(pl.ratingKey, trackKeys: keys)
|
|
||||||
overlays.hud("Added to \(pl.title)")
|
|
||||||
} catch {
|
|
||||||
overlays.hud("Couldn't add: \(error.localizedDescription)", "exclamationmark.triangle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Picks the mix the songs get in a stem playlist: what's playing now, the song's saved mix, or a preset.
|
/// Picks the mix the songs get in a stem playlist: what's playing now, the song's saved mix, or a preset.
|
||||||
@@ -221,18 +196,6 @@ struct MediaMenu {
|
|||||||
overlays.actions("Choose a Mix", acts)
|
overlays.actions("Choose a Mix", acts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func addToStemPlaylist(_ tracks: [Track]) {
|
|
||||||
let lists = g.stemPlaylists
|
|
||||||
overlays.actions("Add \(songs(tracks.count)) to Stem Playlist", [SheetAction("New Stem Playlist…") { newStemPlaylist(tracks) }] + lists.lists.map { pl in
|
|
||||||
SheetAction(pl.title) {
|
|
||||||
chooseMix(tracks) { m in
|
|
||||||
lists.add(pl.id, tracks: tracks, mix: m)
|
|
||||||
overlays.hud("Added to \(pl.title)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func newStemPlaylist(_ tracks: [Track] = [], onCreated: @escaping (StemPlaylist) -> Void = { _ in }) {
|
func newStemPlaylist(_ tracks: [Track] = [], onCreated: @escaping (StemPlaylist) -> Void = { _ in }) {
|
||||||
let lists = g.stemPlaylists
|
let lists = g.stemPlaylists
|
||||||
overlays.prompt("New Stem Playlist", "Every song in it plays with the mix you pick.", "Create", placeholder: "Playlist name") { name in
|
overlays.prompt("New Stem Playlist", "Every song in it plays with the mix you pick.", "Create", placeholder: "Playlist name") { name in
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ final class Overlays {
|
|||||||
struct Confirm { var title: String; var message: String?; var button: String; var destructive: Bool; var run: () -> Void }
|
struct Confirm { var title: String; var message: String?; var button: String; var destructive: Bool; var run: () -> Void }
|
||||||
struct Prompt { var title: String; var message: String?; var initial: String; var button: String; var placeholder: String; var run: (String) -> Void }
|
struct Prompt { var title: String; var message: String?; var initial: String; var button: String; var placeholder: String; var run: (String) -> Void }
|
||||||
struct Hud: Equatable { let id = UUID(); var text: String; var icon: String }
|
struct Hud: Equatable { let id = UUID(); var text: String; var icon: String }
|
||||||
|
struct AddToPlaylist: Identifiable { let id = UUID(); var tracks: [Track]; var title: String; var skip: String? }
|
||||||
|
|
||||||
var actions: Actions?
|
var actions: Actions?
|
||||||
var confirm: Confirm?
|
var confirm: Confirm?
|
||||||
var prompt: Prompt?
|
var prompt: Prompt?
|
||||||
var hud: Hud?
|
var hud: Hud?
|
||||||
|
var addToPlaylist: AddToPlaylist?
|
||||||
/// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's.
|
/// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's.
|
||||||
var level = 0
|
var level = 0
|
||||||
|
|
||||||
@@ -59,6 +61,10 @@ final class Overlays {
|
|||||||
present { self.prompt = Prompt(title: title, message: message, initial: initial, button: button, placeholder: placeholder, run: run) }
|
present { self.prompt = Prompt(title: title, message: message, initial: initial, button: button, placeholder: placeholder, run: run) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addToPlaylist(_ tracks: [Track], title: String, skip: String?) {
|
||||||
|
present { self.addToPlaylist = AddToPlaylist(tracks: tracks, title: title, skip: skip) }
|
||||||
|
}
|
||||||
|
|
||||||
func hud(_ text: String, _ icon: String = "checkmark") {
|
func hud(_ text: String, _ icon: String = "checkmark") {
|
||||||
hudTask?.cancel()
|
hudTask?.cancel()
|
||||||
withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) }
|
withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) }
|
||||||
@@ -75,6 +81,7 @@ struct OverlayHost: ViewModifier {
|
|||||||
let overlays: Overlays
|
let overlays: Overlays
|
||||||
let level: Int
|
let level: Int
|
||||||
@State private var text = ""
|
@State private var text = ""
|
||||||
|
@Environment(\.palette) private var p
|
||||||
|
|
||||||
private var mine: Bool { overlays.level == level }
|
private var mine: Bool { overlays.level == level }
|
||||||
|
|
||||||
@@ -109,6 +116,9 @@ struct OverlayHost: ViewModifier {
|
|||||||
if let m = p.message { Text(m) }
|
if let m = p.message { Text(m) }
|
||||||
}
|
}
|
||||||
.onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" }
|
.onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" }
|
||||||
|
.sheet(item: Binding(get: { mine ? overlays.addToPlaylist : nil }, set: { overlays.addToPlaylist = $0 })) { r in
|
||||||
|
AddToPlaylistSheet(req: r, overlays: overlays).environment(\.palette, p).tint(p.accent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func binding<T>(_ kp: ReferenceWritableKeyPath<Overlays, T?>) -> Binding<Bool> {
|
private func binding<T>(_ kp: ReferenceWritableKeyPath<Overlays, T?>) -> Binding<Bool> {
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@ options:
|
|||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
MARKETING_VERSION: "1.0.8"
|
MARKETING_VERSION: "1.0.9"
|
||||||
CURRENT_PROJECT_VERSION: "9"
|
CURRENT_PROJECT_VERSION: "10"
|
||||||
targets:
|
targets:
|
||||||
Stemify:
|
Stemify:
|
||||||
type: application
|
type: application
|
||||||
|
|||||||
@@ -15,6 +15,15 @@
|
|||||||
"tintColor": "#FA2D48",
|
"tintColor": "#FA2D48",
|
||||||
"category": "entertainment",
|
"category": "entertainment",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "1.0.8",
|
||||||
|
"buildVersion": "9",
|
||||||
|
"date": "2026-10-20T10:02:44-07:00",
|
||||||
|
"localizedDescription": "- Playlists tab now has Plex and Stem sections. Plex playlists are unchanged and use their custom Plex poster when there is one.\n- Stem playlists: each song keeps its own mix (instrumental, a cappella, no drums, custom...). Add songs from any menu, or copy a Plex playlist over in one go. Change one song's mix or the whole list's.\n- Softness slider under the Vocals picker in the lyrics view when vocals are set to Soft.\n\nUnsigned IPA: sideload with AltStore, SideStore or Feather.",
|
||||||
|
"downloadURL": "https://git.iamchrisama.com/cdnutter/stemify/releases/download/ios-v1.0.8/stemify-1.0.8.ipa",
|
||||||
|
"size": 1462423,
|
||||||
|
"minOSVersion": "17.0"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"buildVersion": "8",
|
"buildVersion": "8",
|
||||||
|
|||||||
Reference in new issue
Block a user