iOS 1.0 RC 2: Plex streams under upload caps, shuffle and album radio stations, Recently Added list, photo covers, crossfade
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c0e33b5e3e
commit
2fb8040388
17 files changed
+412
-74
No files matched your search
@@ -78,7 +78,14 @@ struct MediaMenu {
|
||||
if !pl.items.isEmpty {
|
||||
a.append(SheetAction("Change Cover…", icon: "photo") {
|
||||
var seen = Set<String>()
|
||||
var acts: [SheetAction] = [SheetAction("Automatic", icon: "wand.and.stars") { lists.setArt(pl.id, nil) }]
|
||||
var acts: [SheetAction] = [
|
||||
SheetAction("Choose from Photos…", icon: "photo.on.rectangle") {
|
||||
overlays.pickPhoto { data in
|
||||
if !lists.setPhotoArt(pl.id, data) { overlays.hud("Couldn't use that photo", "exclamationmark.triangle") }
|
||||
}
|
||||
},
|
||||
SheetAction("Automatic", icon: "wand.and.stars") { lists.setArt(pl.id, nil) },
|
||||
]
|
||||
for t in pl.tracks {
|
||||
guard let th = t.thumb, seen.insert(th).inserted, seen.count <= 20 else { continue }
|
||||
acts.append(SheetAction(t.album, icon: th == pl.artThumb ? "checkmark" : "photo") { lists.setArt(pl.id, th) })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
struct SheetAction: Identifiable {
|
||||
@@ -29,6 +30,9 @@ final class Overlays {
|
||||
var prompt: Prompt?
|
||||
var hud: Hud?
|
||||
var addToPlaylist: AddToPlaylist?
|
||||
/// Set while the Photos picker is up; gets the picked image's data.
|
||||
var photoPick: ((Data) -> Void)?
|
||||
var photoShown = false
|
||||
/// 1 while the Now Playing sheet is up, so its host shows overlays instead of the root's.
|
||||
var level = 0
|
||||
|
||||
@@ -65,6 +69,10 @@ final class Overlays {
|
||||
present { self.addToPlaylist = AddToPlaylist(tracks: tracks, title: title, skip: skip) }
|
||||
}
|
||||
|
||||
func pickPhoto(_ run: @escaping (Data) -> Void) {
|
||||
present { self.photoPick = run; self.photoShown = true }
|
||||
}
|
||||
|
||||
func hud(_ text: String, _ icon: String = "checkmark") {
|
||||
hudTask?.cancel()
|
||||
withAnimation(.easeOut(duration: 0.15)) { hud = Hud(text: text, icon: icon) }
|
||||
@@ -81,6 +89,7 @@ struct OverlayHost: ViewModifier {
|
||||
let overlays: Overlays
|
||||
let level: Int
|
||||
@State private var text = ""
|
||||
@State private var photo: PhotosPickerItem?
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
private var mine: Bool { overlays.level == level }
|
||||
@@ -116,6 +125,17 @@ struct OverlayHost: ViewModifier {
|
||||
if let m = p.message { Text(m) }
|
||||
}
|
||||
.onChange(of: overlays.prompt?.title) { _, _ in text = overlays.prompt?.initial ?? "" }
|
||||
.photosPicker(isPresented: Binding(get: { mine && overlays.photoShown }, set: { overlays.photoShown = $0 }),
|
||||
selection: $photo, matching: .images)
|
||||
.onChange(of: photo) { _, item in
|
||||
guard let item, let run = overlays.photoPick else { return }
|
||||
photo = nil
|
||||
overlays.photoPick = nil
|
||||
Task { @MainActor in
|
||||
if let data = try? await item.loadTransferable(type: Data.self) { run(data) }
|
||||
else { overlays.hud("Couldn't load that photo", "exclamationmark.triangle") }
|
||||
}
|
||||
}
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ enum Tab: String, CaseIterable, Hashable {
|
||||
}
|
||||
|
||||
enum Route: Hashable {
|
||||
case Artists, Albums, Songs, Playlists, Genres, Downloads, DownloadQueue, TabBar, WebRadio
|
||||
case Artists, Albums, Songs, RecentlyAdded, Playlists, Genres, Downloads, DownloadQueue, TabBar, WebRadio
|
||||
case Eq, EqFilters(profileId: String), EqFilterPage(profileId: String, filterId: String)
|
||||
case Servers, ServerList, Server(String), StemServer, Quality, Storage, Appearance, NowPlayingOptions, StemEditor, About
|
||||
case GenrePage(String), ArtistPage(Artist), AlbumPage(Album), PlaylistPage(Playlist), StemPlaylistPage(String)
|
||||
|
||||
@@ -58,6 +58,7 @@ struct RootView: View {
|
||||
case .Artists: ArtistsScreen()
|
||||
case .Albums: AlbumsScreen()
|
||||
case .Songs: SongsScreen()
|
||||
case .RecentlyAdded: SongsScreen(recent: true)
|
||||
case .Playlists: PlaylistsScreen()
|
||||
case .Genres: GenresScreen()
|
||||
case .Downloads: DownloadsScreen()
|
||||
|
||||
@@ -48,9 +48,9 @@ struct LibraryHome: View {
|
||||
}
|
||||
case "Recently Added":
|
||||
if !added.isEmpty {
|
||||
SectionTitle(title: "Recently Added")
|
||||
SectionTitle(title: "Recently Added", action: "See All") { ui.push(.RecentlyAdded) }
|
||||
ForEach(Array(added.enumerated()), id: \.element.ratingKey) { i, t in
|
||||
TrackRow(track: t, menu: ui.menu.track(t)) { g.playback.playTracks(added, start: i) }
|
||||
TrackRow(track: t, menu: ui.menu.track(t)) { playRecent(from: t, at: i) }
|
||||
.listRow(inset: MARGIN + 60, line: i < added.count - 1)
|
||||
}
|
||||
}
|
||||
@@ -82,8 +82,7 @@ struct LibraryHome: View {
|
||||
SectionTitle(title: "Stations")
|
||||
let stations: [(String, String, () async throws -> Void)] = [
|
||||
("dot.radiowaves.left.and.right", "Library Radio", {
|
||||
let list = try await g.plex.allTracksShuffled()
|
||||
if !list.isEmpty { g.playback.playTracks(list) }
|
||||
try await g.playback.startSongRadio()
|
||||
}),
|
||||
("square.stack", "Random Album Radio", { try await g.playback.startAlbumRadio() }),
|
||||
]
|
||||
@@ -152,6 +151,16 @@ struct LibraryHome: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays the whole Recently Added list (up to 500 songs) from [t], not just the 10 shown here.
|
||||
private func playRecent(from t: Track, at i: Int) {
|
||||
let shown = added
|
||||
Task {
|
||||
let all = (try? await g.plex.recentlyAddedTracks(count: 500)) ?? []
|
||||
if let j = all.firstIndex(where: { $0.ratingKey == t.ratingKey }) { g.playback.playTracks(all, start: j) }
|
||||
else { g.playback.playTracks(shown, start: i) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a station: its songs play in the order given (albums stay whole).
|
||||
private func play(_ start: @escaping () async throws -> Void) {
|
||||
Task {
|
||||
@@ -542,6 +551,8 @@ struct AlbumsScreen: View {
|
||||
// ---- songs (paged) --------------------------------------------------------------------------
|
||||
|
||||
struct SongsScreen: View {
|
||||
/// Recently Added: every song, newest first, with no sort menu.
|
||||
var recent = false
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var tracks: [Track] = []
|
||||
@@ -555,18 +566,21 @@ struct SongsScreen: View {
|
||||
private let sort = SortState("songs", SONG_SORTS)
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
/// Shuffles the whole library, not just the pages loaded so far.
|
||||
/// Shuffles the whole library, not just the pages loaded so far: random songs from the server,
|
||||
/// topped up as they play. Recently Added shuffles what it has loaded.
|
||||
private func shuffleAll() {
|
||||
if recent { g.playback.playTracks(tracks, shuffle: true); return }
|
||||
Task {
|
||||
do { g.playback.playTracks(try await g.plex.allTracksShuffled(), shuffle: true) }
|
||||
catch { g.playback.report("Couldn't load songs: \(error.localizedDescription)") }
|
||||
do { try await g.playback.startSongRadio() }
|
||||
catch { ui.overlays.hud("Couldn't shuffle songs", "exclamationmark.triangle") }
|
||||
}
|
||||
}
|
||||
private var order: String { recent ? "addedAt:desc" : sort.param }
|
||||
|
||||
var body: some View {
|
||||
Page(title: "Songs") {
|
||||
Page(title: recent ? "Recently Added" : "Songs") {
|
||||
if !tracks.isEmpty {
|
||||
if total > 0, !StemsOnly.on, !sort.option.random {
|
||||
if total > 0, !StemsOnly.on, recent || !sort.option.random {
|
||||
Text(songs(total)).font(.subheadline).foregroundStyle(p.label2).padding(.leading, MARGIN).padding(.bottom, 12)
|
||||
}
|
||||
PlayShuffle(play: { g.playback.playTracks(Array(tracks.prefix(1000))) },
|
||||
@@ -584,7 +598,7 @@ struct SongsScreen: View {
|
||||
if loading { LoadingRow() }
|
||||
if let error { ErrorRow(message: error) { loadMore() } }
|
||||
}
|
||||
.toolbar { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } }
|
||||
.toolbar { if !recent { ToolbarItem(placement: .topBarTrailing) { SortMenu(sort: sort) } } }
|
||||
.task { if tracks.isEmpty { loadMore() } }
|
||||
.onChange(of: "\(sort.token)|\(StemsOnly.on)|\(g.settings.libraryKey)") {
|
||||
gen += 1; tracks = []; total = -1; offset = 0; error = nil; loading = false
|
||||
@@ -595,7 +609,7 @@ struct SongsScreen: View {
|
||||
private func loadMore() {
|
||||
if loading || (total >= 0 && offset >= total) { return }
|
||||
loading = true
|
||||
let mine = gen, order = sort.param, random = sort.option.random
|
||||
let mine = gen, order = self.order, random = !recent && sort.option.random
|
||||
Task {
|
||||
do {
|
||||
// Random pages would repeat songs, so it's one shuffled batch.
|
||||
|
||||
@@ -31,6 +31,29 @@ private struct ToggleCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Crossfade length and the same-album exception.
|
||||
private struct CrossfadeCells: View {
|
||||
@Environment(\.palette) private var p
|
||||
private var g: AppGraph { .shared }
|
||||
var body: some View {
|
||||
@Bindable var s = g.settings
|
||||
Picker(selection: Binding(get: { s.crossfadeSec }, set: { s.crossfadeSec = $0; g.playback.crossfadeChanged() })) {
|
||||
Text("Off").tag(0)
|
||||
ForEach(1...12, id: \.self) { Text("\($0) s").tag($0) }
|
||||
} label: {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "arrow.left.arrow.right", bg: 0xFF9500)
|
||||
Text("Crossfade").foregroundStyle(p.label)
|
||||
}
|
||||
}
|
||||
.themedRow()
|
||||
if s.crossfadeSec > 0 {
|
||||
ToggleCell(title: "Sweet Fades", icon: "rectangle.stack.fill", iconBg: 0xFF9500,
|
||||
isOn: Binding(get: { s.sweetFades }, set: { s.sweetFades = $0; g.playback.crossfadeChanged() }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How quiet karaoke's Soft vocals are.
|
||||
private struct SoftVocalsCell: View {
|
||||
@Environment(\.palette) private var p
|
||||
@@ -203,6 +226,7 @@ struct SettingsScreen: View {
|
||||
NavCell(title: "Stem Editor", value: s.lockedPreset.isEmpty ? nil : "Locked", icon: "slider.horizontal.3", iconBg: 0xAF52DE, route: .StemEditor)
|
||||
NavCell(title: "Stem Quality", value: s.stemQuality.label, icon: "gauge.with.dots.needle.67percent", iconBg: 0xFF2D55, route: .Quality)
|
||||
ToggleCell(title: "Keep Mix Between Songs", icon: "speaker.wave.2.fill", iconBg: 0x5856D6, isOn: $s.keepStemMix)
|
||||
CrossfadeCells()
|
||||
SoftVocalsCell()
|
||||
} header: { Header(text: "Listening") }
|
||||
Section {
|
||||
|
||||
Reference in new issue
Block a user