diff --git a/ios/Stemify/App/AppGraph.swift b/ios/Stemify/App/AppGraph.swift index 9c4c18f..702ef5c 100644 --- a/ios/Stemify/App/AppGraph.swift +++ b/ios/Stemify/App/AppGraph.swift @@ -20,6 +20,7 @@ final class AppGraph { let cache: MediaCache let eq: EqController let playback: PlaybackManager + let stemPlaylists: StemPlaylists let downloads: Downloads let coverage: StemCoverage let nowPlaying: NowPlaying @@ -59,6 +60,7 @@ final class AppGraph { cache = MediaCache(dir: media, session: mediaSession) { Int64(settings.cacheLimitGb) << 30 } eq = EqController(dir: support) + stemPlaylists = StemPlaylists(dir: support) playback = PlaybackManager(settings: settings, plex: plex, stems: stems, cache: cache, eq: eq, dir: support) downloads = Downloads(settings: settings, plex: plex, stems: stems, cache: cache, net: net, dir: support) coverage = StemCoverage(plex: plex, stems: stems, settings: settings, dir: support) diff --git a/ios/Stemify/Playback/PlaybackManager.swift b/ios/Stemify/Playback/PlaybackManager.swift index 408b15e..83f33c9 100644 --- a/ios/Stemify/Playback/PlaybackManager.swift +++ b/ios/Stemify/Playback/PlaybackManager.swift @@ -46,6 +46,8 @@ enum SourceKind { case None, Stems, Original } struct QueueItem: Codable, Hashable, Identifiable { var uid: Int64 var track: Track + /// Set when the song came from a stem playlist; it plays with this mix instead of its saved one. + var mix: [String: StemSetting]? var id: Int64 { uid } } @@ -204,16 +206,17 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { // ---- queue -------------------------------------------------------------------------- - private func items(_ tracks: [Track]) -> [QueueItem] { - tracks.map { t in + private func items(_ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) -> [QueueItem] { + tracks.enumerated().map { i, t in defer { nextUid += 1 } - return QueueItem(uid: nextUid, track: t) + return QueueItem(uid: nextUid, track: t, mix: mixes.flatMap { i < $0.count ? $0[i] : nil }) } } - func playTracks(_ tracks: [Track], start: Int = 0, shuffle doShuffle: Bool = false) { + /// `mixes` lines up with `tracks`: each song plays with its own mix (stem playlists). + func playTracks(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil, start: Int = 0, shuffle doShuffle: Bool = false) { if tracks.isEmpty { return } - let items = items(tracks) + let items = items(tracks, mixes) if doShuffle { unshuffled = items let first = start > 0 && start < items.count ? items[start] : items.randomElement()! @@ -228,12 +231,12 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { } } - func playNext(_ tracks: [Track]) { insert(index + 1, tracks) } - func addToQueue(_ tracks: [Track]) { insert(queue.count, tracks) } + func playNext(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(index + 1, tracks, mixes) } + func addToQueue(_ tracks: [Track], mixes: [[String: StemSetting]]? = nil) { insert(queue.count, tracks, mixes) } - private func insert(_ at: Int, _ tracks: [Track]) { + private func insert(_ at: Int, _ tracks: [Track], _ mixes: [[String: StemSetting]]? = nil) { if tracks.isEmpty { return } - let items = items(tracks) + let items = items(tracks, mixes) var q = queue q.insert(contentsOf: items, at: min(max(at, 0), q.count)) queue = q @@ -415,10 +418,11 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { pushMix() } - /// 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] { + /// The mix a song starts with: karaoke if that's on, else its stem playlist mix, else the song's saved mix, + /// else cleared (or kept, per settings). + private func startMix(_ item: QueueItem) -> [String: StemSetting] { if karaoke { return karaokeMix } - return songMixes[t.ratingKey] ?? (settings.keepStemMix ? mix : [:]) + return item.mix ?? songMixes[item.track.ratingKey] ?? (settings.keepStemMix ? mix : [:]) } /// How much of the singer is left in karaoke mode. Soft is a guide under your own voice, as quiet as settings say. @@ -527,7 +531,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { if play && !activateSession() { return } playWhenReady = play buffering = true - mix = startMix(item.track) + mix = startMix(item) changed() save() loadTask = Task { @MainActor [weak self] in @@ -700,7 +704,7 @@ final class PlaybackManager: StemEngineListener, @unchecked Sendable { guard let item = current else { return } durationMs = item.track.durationMs waveform = nil - mix = startMix(item.track) + mix = startMix(item) source = currentEntry != nil ? .Stems : .Original stemNames = Stems.sort(currentEntry?.stems.map(\.name) ?? ["mix"]) pushMix() diff --git a/ios/Stemify/Playback/StemPlaylists.swift b/ios/Stemify/Playback/StemPlaylists.swift new file mode 100644 index 0000000..a6f8cc0 --- /dev/null +++ b/ios/Stemify/Playback/StemPlaylists.swift @@ -0,0 +1,86 @@ +import Foundation +import Observation + +/// One song in a stem playlist, saved with the mix it plays with. +struct StemPlaylistItem: Codable, Hashable, Identifiable { + var uid = UUID().uuidString + var track: Track + var mix: [String: StemSetting] + var id: String { uid } +} + +struct StemPlaylist: Codable, Hashable, Identifiable { + var id = UUID().uuidString + var title: String + var items: [StemPlaylistItem] = [] + var created = Date() + var updated = Date() + + var tracks: [Track] { items.map(\.track) } + var mixes: [[String: StemSetting]] { items.map(\.mix) } + var thumb: String? { items.lazy.compactMap(\.track.thumb).first } + var durationMs: Int64 { items.reduce(0) { $0 + $1.track.durationMs } } +} + +/// Playlists that live on this device only: each song keeps its own stem mix (instrumentals, a cappellas…). +/// Plex playlists can't hold a mix, so these are separate. +@MainActor @Observable +final class StemPlaylists { + private(set) var lists: [StemPlaylist] = [] + @ObservationIgnored private let file: URL + + /// Every stem a song can have, for naming saved mixes without loading the song. + static let allStems = ["vocals", "drums", "bass", "guitar", "piano", "other"] + + init(dir: URL) { + file = dir.appendingPathComponent("stem-playlists.json") + lists = (try? JSONDecoder().decode([StemPlaylist].self, from: Data(contentsOf: file))) ?? [] + } + + func list(_ id: String) -> StemPlaylist? { lists.first { $0.id == id } } + + @discardableResult + func create(_ title: String, tracks: [Track] = [], mix: [String: StemSetting] = [:]) -> StemPlaylist { + let pl = StemPlaylist(title: title, items: tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) }) + lists.insert(pl, at: 0) + save() + return pl + } + + func add(_ id: String, tracks: [Track], mix: [String: StemSetting]) { + edit(id) { $0.items += tracks.map { StemPlaylistItem(track: $0, mix: MixPresets.normalized(mix)) } } + } + + func setItems(_ id: String, _ items: [StemPlaylistItem]) { edit(id) { $0.items = items } } + func remove(_ id: String, item: String) { edit(id) { $0.items.removeAll { $0.uid == item } } } + func setMix(_ id: String, item: String, _ mix: [String: StemSetting]) { + edit(id) { pl in if let i = pl.items.firstIndex(where: { $0.uid == item }) { pl.items[i].mix = MixPresets.normalized(mix) } } + } + func rename(_ id: String, _ title: String) { edit(id) { $0.title = title } } + + func delete(_ id: String) { + lists.removeAll { $0.id == id } + save() + } + + /// "Instrumental", "A Cappella"… or "Custom Mix" for a hand-made one. + static func label(_ mix: [String: StemSetting]) -> String { + if MixPresets.normalized(mix).isEmpty { return MixPresets.full.label } + if MixPresets.current(allStems, mix) == MixPresets.karaoke { return "Instrumental" } + return MixPresets.current(allStems, mix)?.label ?? "Custom Mix" + } + + private func edit(_ id: String, _ change: (inout StemPlaylist) -> Void) { + guard let i = lists.firstIndex(where: { $0.id == id }) else { return } + change(&lists[i]) + lists[i].updated = Date() + save() + } + + private func save() { + let all = lists, file = file + DispatchQueue.global(qos: .utility).async { + if let data = try? JSONEncoder().encode(all) { try? data.write(to: file, options: .atomic) } + } + } +} diff --git a/ios/Stemify/Plex/PlexModels.swift b/ios/Stemify/Plex/PlexModels.swift index ae386fc..36d26c6 100644 --- a/ios/Stemify/Plex/PlexModels.swift +++ b/ios/Stemify/Plex/PlexModels.swift @@ -405,7 +405,7 @@ extension PlexItem { func toArtist() -> Artist { Artist(ratingKey: ratingKey, title: title, sortTitle: titleSort ?? title, thumb: thumb) } func toPlaylist() -> Playlist { - Playlist(ratingKey: ratingKey, title: title, thumb: composite ?? thumb, trackCount: leafCount ?? 0, + Playlist(ratingKey: ratingKey, title: title, thumb: thumb ?? composite, trackCount: leafCount ?? 0, durationMs: duration ?? 0, smart: smart ?? false) } } diff --git a/ios/Stemify/UI/Components/MediaMenu.swift b/ios/Stemify/UI/Components/MediaMenu.swift index 7bd2df0..f610b00 100644 --- a/ios/Stemify/UI/Components/MediaMenu.swift +++ b/ios/Stemify/UI/Components/MediaMenu.swift @@ -17,6 +17,7 @@ struct MediaMenu { 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 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 allowGoTo, let k = t.albumKey { a.append(SheetAction("Go to Album", icon: "square.stack") { @@ -52,10 +53,49 @@ struct MediaMenu { func playlist(_ pl: Playlist, extra: [SheetAction] = []) -> [SheetAction] { let plex = g.plex - return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: extra, + let copy = SheetAction("Copy to Stem Playlist…", icon: "doc.on.doc") { copyToStems(pl) } + return collection(pl.title, [], false, { try await plex.playlistTracks(pl.ratingKey) }, playlistKey: pl.ratingKey, extra: [copy] + extra, confirmDownload: pl.trackCount > 50) } + func stemPlaylist(_ pl: StemPlaylist, extra: [SheetAction] = [], onPage: Bool = false) -> [SheetAction] { + var a = [ + SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") { + g.playback.playNext(pl.tracks, mixes: pl.mixes); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward") + }, + SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") { + g.playback.addToQueue(pl.tracks, mixes: pl.mixes); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward") + }, + ] + let missing = pl.tracks.filter { !g.downloads.isDownloaded($0.ratingKey) } + if !missing.isEmpty { + a.append(SheetAction("Download", icon: "arrow.down.circle") { g.downloads.download(missing); overlays.hud("Downloading", "arrow.down.circle") }) + } + if pl.items.isEmpty { a = [] } + let lists = g.stemPlaylists + a.append(SheetAction("Delete Stem Playlist", icon: "trash", destructive: true) { + overlays.confirm("Delete “\(pl.title)”?", "Stem playlists are only on this device.", "Delete") { lists.delete(pl.id); if onPage { ui.pop() } } + }) + return extra + a + } + + /// A song in a stem playlist: queue it with its mix, change the mix, or take it out. + func stemItem(_ item: StemPlaylistItem, in listId: String) -> [SheetAction] { + let lists = g.stemPlaylists + return [ + SheetAction("Add to Front of Queue", icon: "text.line.first.and.arrowtriangle.forward") { + g.playback.playNext([item.track], mixes: [item.mix]); overlays.hud("Playing Next", "text.line.first.and.arrowtriangle.forward") + }, + SheetAction("Add to End of Queue", icon: "text.line.last.and.arrowtriangle.forward") { + g.playback.addToQueue([item.track], mixes: [item.mix]); overlays.hud("Added to Queue", "text.line.last.and.arrowtriangle.forward") + }, + SheetAction("Change Mix…", icon: "slider.horizontal.3") { + chooseMix([item.track]) { lists.setMix(listId, item: item.uid, $0) } + }, + SheetAction("Remove from Stem Playlist", icon: "minus.circle", destructive: true) { lists.remove(listId, item: item.uid) }, + ] + } + /// For songs already in hand (downloads with no Plex album behind them). func tracks(_ title: String, _ tracks: [Track], extra: [SheetAction] = []) -> [SheetAction] { let have = tracks.filter { g.downloads.isDownloaded($0.ratingKey) } @@ -90,6 +130,7 @@ struct MediaMenu { }) } a.append(SheetAction("Add to Playlist…", icon: "text.badge.plus") { withTracks(load) { addToPlaylist($0, skip: playlistKey) } }) + a.append(SheetAction("Add to Stem Playlist…", icon: "slider.horizontal.3") { withTracks(load) { addToStemPlaylist($0) } }) if !stemmed { a.append(stemNow(load)) } return a + extra } @@ -160,6 +201,66 @@ struct MediaMenu { } } + /// Picks the mix the songs get in a stem playlist: what's playing now, the song's saved mix, or a preset. + func chooseMix(_ tracks: [Track], _ then: @escaping ([String: StemSetting]) -> Void) { + let pm = g.playback + var acts: [SheetAction] = [] + if tracks.count == 1, let t = tracks.first { + if pm.current?.track.ratingKey == t.ratingKey { + let m = pm.mix + acts.append(SheetAction("Current Mix (\(StemPlaylists.label(m)))", icon: "play.circle") { then(m) }) + } + if let m = pm.songMixes[t.ratingKey] { + acts.append(SheetAction("This Song’s Mix (\(StemPlaylists.label(m)))", icon: "bookmark") { then(m) }) + } + } + for p in MixPresets.all { + guard let m = p.build(StemPlaylists.allStems) else { continue } + acts.append(SheetAction(StemPlaylists.label(m)) { then(m) }) + } + 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 }) { + 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 + if tracks.isEmpty { + onCreated(lists.create(name)) + return + } + chooseMix(tracks) { m in + onCreated(lists.create(name, tracks: tracks, mix: m)) + overlays.hud("Created “\(name)”") + } + } + } + + /// Makes a stem playlist from a Plex one, every song with the same mix. + func copyToStems(_ pl: Playlist, onCreated: @escaping (StemPlaylist) -> Void = { _ in }) { + let plex = g.plex + let lists = g.stemPlaylists + overlays.prompt("Copy to Stem Playlist", "Every song gets the mix you pick next; change single songs later.", initial: pl.title, "Next") { name in + withTracks({ try await plex.playlistTracks(pl.ratingKey) }) { tracks in + chooseMix(tracks) { m in + onCreated(lists.create(name, tracks: tracks, mix: m)) + overlays.hud("Copied \(songs(tracks.count))") + } + } + } + } + func newPlaylist(_ tracks: [Track], onCreated: @escaping () -> Void = {}) { let plex = g.plex overlays.prompt("New Playlist", "Name it; you can add more songs later.", "Create", placeholder: "Playlist name") { name in diff --git a/ios/Stemify/UI/Nav.swift b/ios/Stemify/UI/Nav.swift index b70ea12..2f50660 100644 --- a/ios/Stemify/UI/Nav.swift +++ b/ios/Stemify/UI/Nav.swift @@ -18,7 +18,7 @@ enum Route: Hashable { case Artists, Albums, Songs, Playlists case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String) case PlexAccount, StemServer, Quality, Storage, Appearance - case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist) + case ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String) } /// Tabs, their stacks and the Now Playing sheet: the things any screen can ask of the shell. diff --git a/ios/Stemify/UI/RootView.swift b/ios/Stemify/UI/RootView.swift index 568205f..9637a37 100644 --- a/ios/Stemify/UI/RootView.swift +++ b/ios/Stemify/UI/RootView.swift @@ -65,6 +65,7 @@ struct RootView: View { case .ArtistPage(let a): ArtistScreen(artist: a) case .AlbumPage(let a): AlbumScreen(album: a) case .PlaylistPage(let pl): PlaylistScreen(playlist: pl) + case .StemPlaylistPage(let id): StemPlaylistScreen(id: id) } } } diff --git a/ios/Stemify/UI/Screens/NowPlayingScreen.swift b/ios/Stemify/UI/Screens/NowPlayingScreen.swift index f278f14..486b9fe 100644 --- a/ios/Stemify/UI/Screens/NowPlayingScreen.swift +++ b/ios/Stemify/UI/Screens/NowPlayingScreen.swift @@ -912,6 +912,15 @@ private struct LyricsPanel: View { .pickerStyle(.segmented) } .padding(.horizontal, 28).padding(.top, 8) + if g.playback.karaokeVocals == .Soft { + let db = g.settings.karaokeSoftDb + HStack(spacing: 12) { + Text("Softness").font(.subheadline.weight(.semibold)).foregroundStyle(p.label2) + Slider(value: Binding(get: { Double(db) }, set: { g.playback.setKaraokeSoftDb(Int($0.rounded())) }), in: -30...(-3), step: 1) + Text("\(db) dB").font(.caption).monospacedDigit().foregroundStyle(p.label2).frame(width: 46, alignment: .trailing) + } + .padding(.horizontal, 28).padding(.top, 8) + } content } .task(id: track.ratingKey) { await follow() } diff --git a/ios/Stemify/UI/Screens/PlaylistScreens.swift b/ios/Stemify/UI/Screens/PlaylistScreens.swift index a1229d7..9fee23d 100644 --- a/ios/Stemify/UI/Screens/PlaylistScreens.swift +++ b/ios/Stemify/UI/Screens/PlaylistScreens.swift @@ -2,29 +2,49 @@ import SwiftUI // ---- playlists ------------------------------------------------------------------------------ +enum PlaylistKind: String, CaseIterable { case Plex, Stem } + struct PlaylistsScreen: View { @Environment(Ui.self) private var ui @State private var lists: LoadState<[Playlist]> = .loading @State private var reload = 0 + @AppStorage("playlistKind") private var kind = PlaylistKind.Plex private let sort = SortState("playlists", PLAYLIST_SORTS) private var g: AppGraph { .shared } var body: some View { Page(title: "Playlists") { - Loaded(state: lists, retry: { reload += 1 }) { list in - if list.isEmpty { - EmptyState(icon: "music.note.list", title: "No Playlists", - message: "Hold any song, album, artist or playlist and choose “Add to Playlist”, or + to make one from your queue. They sync with Plexamp.") + Picker("Kind", selection: $kind) { + Text("Plex").tag(PlaylistKind.Plex) + Text("Stem").tag(PlaylistKind.Stem) + } + .pickerStyle(.segmented) + .padding(.horizontal, MARGIN).padding(.bottom, 8) + if kind == .Plex { + Loaded(state: lists, retry: { reload += 1 }) { list in + if list.isEmpty { + EmptyState(icon: "music.note.list", title: "No Playlists", + message: "Hold any song, album, artist or playlist and choose “Add to Playlist”, or + to make one from your queue. They sync with Plexamp.") + } + ForEach(list) { pl in + PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl)) + } } - ForEach(list) { pl in - PlaylistRow(playlist: pl).onTapGesture { ui.push(.PlaylistPage(pl)) }.actionsMenu(ui.menu.playlist(pl)) + } else { + let stems = g.stemPlaylists.lists + if stems.isEmpty { + EmptyState(icon: "slider.horizontal.3", title: "No Stem Playlists", + message: "Each song plays with its own mix, like an instrumental or a cappella. Hold any song and choose “Add to Stem Playlist”, or copy a Plex playlist with +.") + } + ForEach(stems) { pl in + StemPlaylistRow(playlist: pl).onTapGesture { ui.push(.StemPlaylistPage(pl.id)) }.actionsMenu(ui.menu.stemPlaylist(pl)) } } } .toolbar { - ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } + if kind == .Plex { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } } ToolbarItem(placement: .topBarTrailing) { - Button { newPlaylist() } label: { Image(systemName: "plus") } + Button { kind == .Plex ? newPlaylist() : newStemPlaylist() } label: { Image(systemName: "plus") } } } .task(id: "\(g.settings.musicSection)|\(sort.param)|\(reload)") { @@ -45,6 +65,130 @@ struct PlaylistsScreen: View { if acts.isEmpty { ui.overlays.hud("Play something first, or use “Add to Playlist” on any song.", "info.circle") } else { ui.overlays.actions("New Playlist", acts) } } + + private func newStemPlaylist() { + let menu = ui.menu + let open: (StemPlaylist) -> Void = { ui.push(.StemPlaylistPage($0.id)) } + ui.overlays.actions("New Stem Playlist", [ + SheetAction("Empty Stem Playlist…", icon: "plus") { menu.newStemPlaylist(onCreated: open) }, + SheetAction("Copy from Plex Playlist…", icon: "doc.on.doc") { + Task { + guard let plex = try? await g.plex.playlists() else { + ui.overlays.hud("Couldn't load playlists", "exclamationmark.triangle") + return + } + if plex.isEmpty { ui.overlays.hud("No Plex playlists to copy", "info.circle"); return } + ui.overlays.actions("Copy from Plex", plex.map { pl in SheetAction(pl.title) { menu.copyToStems(pl, onCreated: open) } }) + } + }, + ]) + } +} + +struct StemPlaylistRow: View { + let playlist: StemPlaylist + @Environment(\.palette) private var p + var body: some View { + HStack(spacing: 14) { + Artwork(thumb: playlist.thumb, size: 60, corner: 6, placeholder: "slider.horizontal.3") + VStack(alignment: .leading, spacing: 2) { + Text(playlist.title).foregroundStyle(p.label).lineLimit(1) + Text(songs(playlist.items.count)).font(.subheadline).foregroundStyle(p.label2) + } + Spacer(minLength: 8) + Tag(text: "STEMS") + Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold)).foregroundStyle(p.label3) + } + .frame(height: 76) + .contentShape(Rectangle()) + .listRow(inset: 94, trailing: 16) + } +} + +/// A stem playlist: lives on this device, and every song plays with its saved mix. +struct StemPlaylistScreen: View { + let id: String + @Environment(Ui.self) private var ui + @Environment(\.palette) private var p + @State private var editing = false + @State private var items: [StemPlaylistItem] = [] + private var g: AppGraph { .shared } + + var body: some View { + if let pl = g.stemPlaylists.list(id) { + page(pl) + } else { + EmptyState(icon: "slider.horizontal.3", title: "Playlist Gone", message: "This stem playlist was deleted.") + } + } + + private func page(_ pl: StemPlaylist) -> some View { + Group { + if editing { + List { + header(pl).listRowBackground(Color.clear).listRowSeparator(.hidden) + ForEach(items) { it in + TrackRow(track: it.track, subtitle: StemPlaylists.label(it.mix)) {}.listRowBackground(p.bg) + } + .onDelete { items.remove(atOffsets: $0) } + .onMove { items.move(fromOffsets: $0, toOffset: $1) } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(p.bg) + .environment(\.editMode, .constant(.active)) + .navigationTitle(pl.title) + .navigationBarTitleDisplayMode(.inline) + } else { + Page(title: pl.title, large: false) { + header(pl) + if pl.items.isEmpty { + EmptyState(icon: "slider.horizontal.3", title: "No Songs", + message: "Hold any song, album or playlist and choose “Add to Stem Playlist”.") + } + ForEach(Array(pl.items.enumerated()), id: \.element.id) { i, it in + TrackRow(track: it.track, subtitle: "\(it.track.artist) · \(StemPlaylists.label(it.mix))", menu: ui.menu.stemItem(it, in: pl.id)) { + g.playback.playTracks(pl.tracks, mixes: pl.mixes, start: i) + } + .listRow(inset: MARGIN + 60) + } + } + } + } + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + if editing { Button("Done") { g.stemPlaylists.setItems(pl.id, items); editing = false }.bold() } + else if !pl.items.isEmpty { Button("Edit") { items = pl.items; editing = true } } + if !editing { MoreMenu(items: moreItems(pl)) } + } + } + } + + private func header(_ pl: StemPlaylist) -> some View { + VStack(spacing: 0) { + Artwork(thumb: pl.thumb, size: 230, corner: 10, placeholder: "slider.horizontal.3") + Text(pl.title).font(.title2.bold()).foregroundStyle(p.label).multilineTextAlignment(.center).lineLimit(2) + .padding(.horizontal, 32).padding(.top, 14) + Text(pl.items.isEmpty ? "Stem Playlist" : "\(songs(pl.items.count)) · \(formatMinutes(pl.durationMs))").font(.subheadline).foregroundStyle(p.label2) + if !editing && !pl.items.isEmpty { + PlayShuffle(play: { g.playback.playTracks(pl.tracks, mixes: pl.mixes) }, + shuffle: { g.playback.playTracks(pl.tracks, mixes: pl.mixes, shuffle: true) }).padding(.top, 14) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 8).padding(.bottom, 16) + } + + private func moreItems(_ pl: StemPlaylist) -> [SheetAction] { + let lists = g.stemPlaylists + let rename = SheetAction("Rename…", icon: "pencil") { + ui.overlays.prompt("Rename Stem Playlist", nil, initial: pl.title, "Save") { lists.rename(pl.id, $0) } + } + let mixAll = SheetAction("Set Mix for All Songs…", icon: "slider.horizontal.3") { + ui.menu.chooseMix(pl.tracks) { m in lists.setItems(pl.id, pl.items.map { var it = $0; it.mix = MixPresets.normalized(m); return it }) } + } + return ui.menu.stemPlaylist(pl, extra: pl.items.isEmpty ? [rename] : [rename, mixAll], onPage: true) + } } struct PlaylistRow: View { diff --git a/ios/project.yml b/ios/project.yml index 2b3ebb2..939ad5b 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -6,8 +6,8 @@ options: settings: base: SWIFT_VERSION: "5.0" - MARKETING_VERSION: "1.0.7" - CURRENT_PROJECT_VERSION: "8" + MARKETING_VERSION: "1.0.8" + CURRENT_PROJECT_VERSION: "9" targets: Stemify: type: application