ios: reorderable tab bar with more tabs, debug log export, AirPlay picker, more room above seeker
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a0fe35a8d8
commit
926ad6ee99
10 files changed
+350
-10
No files matched your search
@@ -642,3 +642,56 @@ struct DownloadsScreen: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- genres -----------------------------------------------------------------------------------
|
||||
|
||||
struct GenresScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
@Environment(\.palette) private var p
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var reload = 0
|
||||
|
||||
var body: some View {
|
||||
Page(title: "Genres") {
|
||||
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||
let counts = Dictionary(grouping: list.compactMap(\.genre).filter { !$0.isEmpty }, by: { $0 }).mapValues(\.count)
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(counts.keys.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, id: \.self) { g in
|
||||
Button { ui.push(.GenrePage(g)) } label: {
|
||||
HStack {
|
||||
Text(g).foregroundStyle(p.label)
|
||||
Spacer()
|
||||
Text("\(counts[g] ?? 0)").foregroundStyle(p.label2)
|
||||
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label2)
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Divider().padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GenreScreen: View {
|
||||
@Environment(Ui.self) private var ui
|
||||
let genre: String
|
||||
@State private var albums: LoadState<[Album]> = .loading
|
||||
@State private var reload = 0
|
||||
|
||||
var body: some View {
|
||||
Page(title: genre) {
|
||||
Loaded(state: albums, retry: { reload += 1 }) { list in
|
||||
AlbumGrid(albums: list.filter { $0.genre == genre }, menu: { ui.menu.album($0) }, open: { ui.push(.AlbumPage($0)) }).padding(.top, 8)
|
||||
}
|
||||
}
|
||||
.task(id: "\(AppGraph.shared.settings.musicSection)|\(reload)") {
|
||||
await LoadState.load($albums) { try await AppGraph.shared.plex.albums(sort: "titleSort") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,7 @@ struct NowPlayingScreen: View {
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
ProgressVolumeBar(played: c.label, unplayed: c.label3, centerLabel: badge(track))
|
||||
.padding(.top, wide ? 24 : panel == .Art ? 34 : 18)
|
||||
.padding(.top, wide ? 24 : panel == .Art ? 34 : 30)
|
||||
HStack {
|
||||
SideToggle(symbol: "shuffle", on: pm.shuffle, action: pm.toggleShuffle)
|
||||
Spacer()
|
||||
@@ -199,6 +199,7 @@ struct NowPlayingScreen: View {
|
||||
PanelToggle(symbol: "slider.vertical.3", on: panel == .Mixer) { show(.Mixer) }
|
||||
SpeedMenu()
|
||||
Button { ui.showInSettings(.Eq) } label: { routeLabel(c) }.buttonStyle(PressFade()).frame(maxWidth: .infinity)
|
||||
AirPlayButton(tint: UIColor(c.label2), active: UIColor(c.accent)).frame(width: 40, height: 40)
|
||||
PanelToggle(symbol: "music.mic", on: pm.karaoke) { karaoke() }
|
||||
PanelToggle(symbol: "list.bullet", on: panel == .Queue) { show(.Queue) }
|
||||
}
|
||||
|
||||
@@ -153,6 +153,11 @@ struct SettingsScreen: View {
|
||||
} header: { Header(text: "Backup") } footer: {
|
||||
Footer(text: "Stem playlists, their mixes and sound settings. Tokens and server addresses are left out. Save to iCloud Drive from the Files sheet.")
|
||||
}
|
||||
Section {
|
||||
DebugCells()
|
||||
} header: { Header(text: "Debug") } footer: {
|
||||
Footer(text: "Verbose adds playback and network detail. Tokens are removed from the log.")
|
||||
}
|
||||
Section {
|
||||
NavCell(title: "Appearance", value: s.theme.label, icon: "paintbrush.fill", iconBg: 0x007AFF, route: .Appearance)
|
||||
} header: { Header(text: "Display") } footer: {
|
||||
@@ -508,7 +513,7 @@ struct StorageScreen: View {
|
||||
let usage = g.cache.usage
|
||||
List {
|
||||
Section {
|
||||
Button { ui.selectTab(.Downloads) } label: {
|
||||
Button { ui.openDownloads() } label: {
|
||||
HStack {
|
||||
ValueCell(title: "Downloads", value: gb(usage.pinnedBytes))
|
||||
Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(p.label3)
|
||||
@@ -598,6 +603,9 @@ struct AppearanceScreen: View {
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
} header: { Header(text: "Now Playing") } footer: { Footer(text: "Paints the Now Playing screen in one solid colour taken from the album art.") }
|
||||
Section {
|
||||
NavigationLink(value: Route.TabBar) { Text("Tab Bar").foregroundStyle(c.label) }.themedRow()
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
@@ -605,3 +613,111 @@ struct AppearanceScreen: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- tab bar editor ---------------------------------------------------------------------------
|
||||
|
||||
struct TabBarScreen: View {
|
||||
@Environment(\.palette) private var c
|
||||
private var g: AppGraph { .shared }
|
||||
|
||||
var body: some View {
|
||||
@Bindable var s = g.settings
|
||||
let shown = Tab.layout(s.tabLayout)
|
||||
let hidden = Tab.allCases.filter { !shown.contains($0) }
|
||||
List {
|
||||
Section {
|
||||
ForEach(shown, id: \.self) { t in
|
||||
HStack {
|
||||
Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label)
|
||||
if t == .Settings { Spacer(); Text("Always on").font(.footnote).foregroundStyle(c.label2) }
|
||||
}
|
||||
.themedRow()
|
||||
.deleteDisabled(t == .Settings)
|
||||
}
|
||||
.onMove { from, to in
|
||||
var l = shown
|
||||
l.move(fromOffsets: from, toOffset: to)
|
||||
s.tabLayout = Tab.encode(l)
|
||||
}
|
||||
.onDelete { idx in
|
||||
s.tabLayout = Tab.encode(shown.enumerated().filter { !idx.contains($0.offset) }.map(\.element))
|
||||
}
|
||||
} header: { Header(text: "In the bar") } footer: { Footer(text: "Drag to reorder, swipe to remove. The bar holds \(Tab.maxVisible) tabs.") }
|
||||
if !hidden.isEmpty {
|
||||
Section {
|
||||
ForEach(hidden, id: \.self) { t in
|
||||
Button { s.tabLayout = Tab.encode(shown + [t]) } label: {
|
||||
HStack {
|
||||
Label(t.rawValue, systemImage: t.icon).foregroundStyle(c.label)
|
||||
Spacer()
|
||||
Image(systemName: "plus.circle.fill").foregroundStyle(shown.count < Tab.maxVisible ? c.accent : c.label2)
|
||||
}
|
||||
}
|
||||
.disabled(shown.count >= Tab.maxVisible)
|
||||
.themedRow()
|
||||
}
|
||||
} header: { Header(text: "Available") }
|
||||
}
|
||||
Section {
|
||||
Button("Reset to default") { s.tabLayout = Tab.encode(Tab.defaultLayout) }.themedRow()
|
||||
}
|
||||
}
|
||||
.environment(\.editMode, .constant(.active))
|
||||
.listStyle(.insetGrouped)
|
||||
.themedList()
|
||||
.navigationTitle("Tab Bar")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---- debug ----------------------------------------------------------------------------------
|
||||
|
||||
private struct DebugCells: View {
|
||||
@AppStorage(Log.verboseKey) private var verbose = false
|
||||
@State private var logURL: URL?
|
||||
@State private var viewing = false
|
||||
@Environment(\.palette) private var p
|
||||
|
||||
var body: some View {
|
||||
Toggle(isOn: $verbose) {
|
||||
HStack(spacing: 14) {
|
||||
SettingsIcon(symbol: "ladybug.fill", bg: 0xFF9500)
|
||||
Text("Verbose Logging").foregroundStyle(p.label)
|
||||
}
|
||||
}
|
||||
.tint(Color(hex: 0x34C759))
|
||||
.themedRow()
|
||||
Button { viewing = true } label: { Text("View Log").foregroundStyle(p.label) }.themedRow()
|
||||
if let logURL {
|
||||
ShareLink(item: logURL) { Text("Export Log").foregroundStyle(p.accent) }.themedRow()
|
||||
} else {
|
||||
Button { logURL = Log.shared.export() } label: { Text("Prepare Log for Export").foregroundStyle(p.accent) }.themedRow()
|
||||
}
|
||||
Button(role: .destructive) { Log.shared.clear(); logURL = nil } label: { Text("Clear Log") }.themedRow()
|
||||
.sheet(isPresented: $viewing) { LogViewer() }
|
||||
}
|
||||
}
|
||||
|
||||
private struct LogViewer: View {
|
||||
@State private var text = Log.shared.text()
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
Text(text.isEmpty ? "Nothing logged yet." : text)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.defaultScrollAnchor(.bottom)
|
||||
.navigationTitle("Log")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } }
|
||||
ToolbarItem(placement: .primaryAction) { Button("Refresh") { text = Log.shared.text() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user