ios: Liquid Glass mini player, adjustable karaoke softness (1.0.7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e98d595365
commit
d52a3e257f
6 files changed
+163
-56
No files matched your search
@@ -794,56 +794,100 @@ 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) {
|
||||
Text(t.title).font(.subheadline.weight(.semibold)).foregroundStyle(p.label).lineLimit(1)
|
||||
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())
|
||||
HStack(spacing: 0) {
|
||||
Artwork(thumb: t.thumb, size: 44, corner: 8).padding(.trailing, 12)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(t.title).font(.subheadline.weight(.semibold)).foregroundStyle(p.label).lineLimit(1)
|
||||
Text(t.artist).font(.footnote).foregroundStyle(p.label2).lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 8).padding(.trailing, 6)
|
||||
.frame(height: 60)
|
||||
.background {
|
||||
// A solid card in the app's own colours instead of system glass.
|
||||
let shape = RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
ZStack {
|
||||
shape.fill(p.cell)
|
||||
shape.fill(p.fill.opacity(p.dark ? 0.5 : 0))
|
||||
shape.strokeBorder(p.separator, lineWidth: 1 / scale)
|
||||
}
|
||||
.shadow(color: .black.opacity(p.dark ? 0.5 : 0.12), radius: 12, y: 4)
|
||||
Spacer(minLength: 6)
|
||||
MiniTransport(color: p.label, next: true)
|
||||
}
|
||||
.padding(.leading, 8).padding(.trailing, 6)
|
||||
.frame(height: 60)
|
||||
.background {
|
||||
// A solid card in the app's own colours instead of system glass.
|
||||
let shape = RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
ZStack {
|
||||
shape.fill(p.cell)
|
||||
shape.fill(p.fill.opacity(p.dark ? 0.5 : 0))
|
||||
shape.strokeBorder(p.separator, lineWidth: 1 / scale)
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
// Thin progress line along the bottom of the card.
|
||||
TimelineView(.animation(minimumInterval: 0.25, paused: !pm.playWhenReady)) { _ in
|
||||
let d = pm.durationMs
|
||||
let f = d > 0 ? min(max(Double(pm.positionMs()) / Double(d), 0), 1) : 0
|
||||
GeometryReader { geo in p.accent.frame(width: geo.size.width * f, height: 2) }.frame(height: 2)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.shadow(color: .black.opacity(p.dark ? 0.5 : 0.12), radius: 12, y: 4)
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
// Thin progress line along the bottom of the card.
|
||||
TimelineView(.animation(minimumInterval: 0.25, paused: !pm.playWhenReady)) { _ in
|
||||
let d = pm.durationMs
|
||||
let f = d > 0 ? min(max(Double(pm.positionMs()) / Double(d), 0), 1) : 0
|
||||
GeometryReader { geo in p.accent.frame(width: geo.size.width * f, height: 2) }.frame(height: 2)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 6)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { ui.openNowPlaying() }
|
||||
.gesture(DragGesture(minimumDistance: 20).onEnded { v in if v.translation.height < -30 { ui.openNowPlaying() } })
|
||||
.actionsMenu(ui.menu.track(t))
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 6)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { ui.openNowPlaying() }
|
||||
.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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user