Align iOS player UI with new glass design
Some checks failed
CI / UI / Build (pull_request) Failing after 6s
CI / Scraper / Test (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 11s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Test (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s

This commit is contained in:
Admin
2026-03-07 23:45:40 +05:00
parent bb604019fc
commit 3d9d8ab365
2 changed files with 126 additions and 133 deletions

View File

@@ -23,19 +23,19 @@ struct RootTabView: View {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
TabView(selection: $selectedTab) { TabView(selection: $selectedTab) {
HomeView() HomeView()
.tabItem { Label("Home", systemImage: "house.fill") } .tabItem { Label("Home", systemImage: "house") }
.tag(Tab.home) .tag(Tab.home)
LibraryView() LibraryView()
.tabItem { Label("Library", systemImage: "books.vertical.fill") } .tabItem { Label("Library", systemImage: "books.vertical") }
.tag(Tab.library) .tag(Tab.library)
BrowseView() BrowseView()
.tabItem { Label("Discover", systemImage: "globe") } .tabItem { Label("Discover", systemImage: "sparkles") }
.tag(Tab.browse) .tag(Tab.browse)
ProfileView() ProfileView()
.tabItem { Label("Profile", systemImage: "person.fill") } .tabItem { Label("Profile", systemImage: "person.crop.circle") }
.tag(Tab.profile) .tag(Tab.profile)
} }
// Reserve space for the mini-player above the tab bar so scroll content // Reserve space for the mini-player above the tab bar so scroll content
@@ -49,7 +49,7 @@ struct RootTabView: View {
// Mini-player pinned above the tab bar (hidden while full player is open) // Mini-player pinned above the tab bar (hidden while full player is open)
if audioPlayer.isActive && !showFullPlayer { if audioPlayer.isActive && !showFullPlayer {
MiniPlayerView(showFullPlayer: $showFullPlayer) MiniPlayerView(showFullPlayer: $showFullPlayer)
.padding(.bottom, tabBarHeight) .padding(.bottom, max(tabBarHeight - 6, 0))
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive) .animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
} }

View File

@@ -7,28 +7,30 @@ struct MiniPlayerView: View {
@Binding var showFullPlayer: Bool @Binding var showFullPlayer: Bool
@EnvironmentObject var audioPlayer: AudioPlayerService @EnvironmentObject var audioPlayer: AudioPlayerService
private let capsuleRadius: CGFloat = 24
/// Live drag offset while the user is swiping up (negative = moving up). /// Live drag offset while the user is swiping up (negative = moving up).
@State private var dragOffset: CGFloat = 0 @State private var dragOffset: CGFloat = 0
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 6) {
// Progress line thin amber strip at the very top // Progress line thin amber strip at the very top
GeometryReader { geo in GeometryReader { geo in
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
Rectangle().fill(Color.white.opacity(0.08)).frame(height: 2) Capsule().fill(Color.white.opacity(0.12)).frame(height: 2)
Rectangle() Capsule()
.fill(Color.amber) .fill(Color.amber)
.frame(width: geo.size.width * progress, height: 2) .frame(width: geo.size.width * progress, height: 2)
} }
} }
.frame(height: 2) .frame(height: 2)
HStack(spacing: 14) { HStack(spacing: 12) {
// Cover thumbnail // Cover thumbnail
Button { showFullPlayer = true } label: { Button { showFullPlayer = true } label: {
AsyncCoverImage(url: audioPlayer.coverURL) AsyncCoverImage(url: audioPlayer.coverURL)
.frame(width: 44, height: 44) .frame(width: 46, height: 46)
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -39,7 +41,7 @@ struct MiniPlayerView: View {
.lineLimit(1) .lineLimit(1)
Text(chapterLabel) Text(chapterLabel)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.white.opacity(0.65))
.lineLimit(1) .lineLimit(1)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -52,19 +54,20 @@ struct MiniPlayerView: View {
ProgressView() ProgressView()
.tint(.amber) .tint(.amber)
.scaleEffect(0.85) .scaleEffect(0.85)
.frame(width: 36, height: 36) .frame(width: 34, height: 34)
case .ready: case .ready:
Button { audioPlayer.togglePlayPause() } label: { Button { audioPlayer.togglePlayPause() } label: {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 18, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundStyle(.primary) .foregroundStyle(.white)
.frame(width: 36, height: 36) .frame(width: 34, height: 34)
.background(.white.opacity(0.12), in: Circle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
case .error: case .error:
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.red) .foregroundStyle(.red)
.frame(width: 36, height: 36) .frame(width: 34, height: 34)
default: default:
EmptyView() EmptyView()
} }
@@ -73,17 +76,34 @@ struct MiniPlayerView: View {
// Dismiss // Dismiss
Button { audioPlayer.stop() } label: { Button { audioPlayer.stop() } label: {
Image(systemName: "xmark") Image(systemName: "xmark")
.font(.system(size: 12, weight: .bold)) .font(.system(size: 11, weight: .bold))
.foregroundStyle(.secondary) .foregroundStyle(.white.opacity(0.7))
.frame(width: 30, height: 30) .frame(width: 28, height: 28)
.background(.white.opacity(0.12), in: Circle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 10) .padding(.vertical, 6)
} }
.background(.ultraThinMaterial) .padding(.horizontal, 10)
.shadow(color: .black.opacity(0.15), radius: 6, y: -2) .padding(.vertical, 4)
.frame(maxWidth: 520)
.background(
RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(Color.black.opacity(0.18))
)
.overlay(
RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
.overlay(
RoundedRectangle(cornerRadius: capsuleRadius, style: .continuous)
.stroke(Color.white.opacity(0.06), lineWidth: 1)
.blur(radius: 0.8)
)
.shadow(color: .black.opacity(0.25), radius: 16, y: 8)
// Follow finger upward while dragging, spring back or expand on release // Follow finger upward while dragging, spring back or expand on release
.offset(y: min(dragOffset, 0)) .offset(y: min(dragOffset, 0))
.gesture( .gesture(
@@ -131,69 +151,82 @@ struct FullPlayerView: View {
/// Called when the view wants to close itself (Done button or drag-to-dismiss). /// Called when the view wants to close itself (Done button or drag-to-dismiss).
var onDismiss: () -> Void = {} var onDismiss: () -> Void = {}
@State private var isShuffled = false
@State private var isRepeating = false
var body: some View { var body: some View {
ZStack { ZStack {
// Background: blurred cover art LinearGradient(
GeometryReader { geo in colors: [
KFImage(URL(string: audioPlayer.coverURL)) Color(red: 73 / 255, green: 55 / 255, blue: 102 / 255),
.resizable() Color(red: 34 / 255, green: 24 / 255, blue: 49 / 255)
.scaledToFill() ],
.frame(width: geo.size.width, height: geo.size.height) startPoint: .top,
.clipped() endPoint: .bottom
.blur(radius: 40, opaque: true) )
.overlay(Color.black.opacity(0.55))
.ignoresSafeArea()
}
.ignoresSafeArea() .ignoresSafeArea()
// Content
VStack(spacing: 0) { VStack(spacing: 0) {
// Drag handle pill visual cue that you can swipe down to close // Drag handle pill visual cue that you can swipe down to close
Capsule() Capsule()
.fill(Color.white.opacity(0.35)) .fill(Color.white.opacity(0.35))
.frame(width: 36, height: 4) .frame(width: 46, height: 5)
.padding(.top, 12) .padding(.top, 12)
.padding(.bottom, 8) .padding(.bottom, 14)
Spacer(minLength: 0)
// Cover art // Cover art
KFImage(URL(string: audioPlayer.coverURL)) KFImage(URL(string: audioPlayer.coverURL))
.resizable() .resizable()
.placeholder { .placeholder {
RoundedRectangle(cornerRadius: 18) RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(.white.opacity(0.1)) .fill(.white.opacity(0.12))
.overlay( .overlay(
Image(systemName: "book.closed") Image(systemName: "music.note")
.font(.system(size: 48)) .font(.system(size: 52))
.foregroundStyle(.white.opacity(0.4)) .foregroundStyle(.white.opacity(0.35))
) )
} }
.scaledToFill() .scaledToFill()
.frame(width: 260, height: 260) .frame(width: 310, height: 310)
.clipShape(RoundedRectangle(cornerRadius: 18)) .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.shadow(color: .black.opacity(0.5), radius: 24, y: 12) .shadow(color: .black.opacity(0.45), radius: 26, y: 16)
.padding(.horizontal, 48) .padding(.horizontal, 32)
Spacer(minLength: 0)
// Title block // Title block
VStack(spacing: 6) { VStack(spacing: 8) {
Text((audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle).strippingTrailingDate()) Text((audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle).strippingTrailingDate())
.font(.title3.weight(.bold)) .font(.title3.weight(.bold))
.foregroundStyle(.white) .foregroundStyle(.white)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(2) .lineLimit(2)
Text(audioPlayer.bookTitle) Text(audioPlayer.bookTitle)
.font(.subheadline) .font(.title3.weight(.regular))
.foregroundStyle(.white.opacity(0.65)) .foregroundStyle(.white.opacity(0.7))
.lineLimit(1) .lineLimit(1)
} }
.padding(.horizontal, 32) .padding(.horizontal, 32)
.padding(.top, 28) .padding(.top, 22)
// Actions row
HStack(spacing: 22) {
Button {} label: {
Image(systemName: "heart")
.font(.system(size: 20, weight: .regular))
}
Button {} label: {
Image(systemName: "star")
.font(.system(size: 20, weight: .regular))
}
Button {} label: {
Image(systemName: "ellipsis")
.font(.system(size: 20, weight: .semibold))
}
}
.foregroundStyle(.white.opacity(0.9))
.padding(.top, 18)
// Seek bar // Seek bar
VStack(spacing: 6) { VStack(spacing: 8) {
PlayerSlider( PlayerSlider(
value: Binding( value: Binding(
get: { audioPlayer.currentTime }, get: { audioPlayer.currentTime },
@@ -204,40 +237,41 @@ struct FullPlayerView: View {
HStack { HStack {
Text(formatTime(audioPlayer.currentTime)) Text(formatTime(audioPlayer.currentTime))
Spacer() Spacer()
Text(formatTime(audioPlayer.duration)) Text("-\(formatTime(max(audioPlayer.duration - audioPlayer.currentTime, 0)))")
} }
.font(.caption.monospacedDigit()) .font(.caption.monospacedDigit())
.foregroundStyle(.white.opacity(0.5)) .foregroundStyle(.white.opacity(0.65))
} }
.padding(.horizontal, 28) .padding(.horizontal, 28)
.padding(.top, 24) .padding(.top, 18)
// Controls // Controls
HStack(spacing: 0) { HStack(spacing: 26) {
// skip back 15s Button { isShuffled.toggle() } label: {
Image(systemName: "shuffle")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(isShuffled ? .white : .white.opacity(0.45))
}
Button { audioPlayer.skip(by: -15) } label: { Button { audioPlayer.skip(by: -15) } label: {
Image(systemName: "gobackward.15") Image(systemName: "gobackward.15")
.font(.system(size: 26, weight: .regular)) .font(.system(size: 24, weight: .regular))
.foregroundStyle(.white.opacity(0.9)) .foregroundStyle(.white)
.frame(maxWidth: .infinity)
} }
.buttonStyle(.plain)
// play / pause
Button { audioPlayer.togglePlayPause() } label: { Button { audioPlayer.togglePlayPause() } label: {
ZStack { ZStack {
Circle() Circle()
.fill(Color.amber) .fill(Color.white)
.frame(width: 72, height: 72) .frame(width: 72, height: 72)
.shadow(color: Color.amber.opacity(0.45), radius: 14, y: 4) .shadow(color: .black.opacity(0.3), radius: 10, y: 6)
if audioPlayer.status == .generating { if audioPlayer.status == .generating {
ProgressView() ProgressView()
.tint(.white) .tint(Color.black.opacity(0.7))
.scaleEffect(1.1)
} else { } else {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 28, weight: .semibold)) .font(.system(size: 28, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(Color.black.opacity(0.85))
.offset(x: audioPlayer.isPlaying ? 0 : 2) .offset(x: audioPlayer.isPlaying ? 0 : 2)
} }
} }
@@ -245,76 +279,35 @@ struct FullPlayerView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(audioPlayer.status == .generating) .disabled(audioPlayer.status == .generating)
// skip forward 30s
Button { audioPlayer.skip(by: 30) } label: { Button { audioPlayer.skip(by: 30) } label: {
Image(systemName: "goforward.30") Image(systemName: "goforward.30")
.font(.system(size: 26, weight: .regular)) .font(.system(size: 24, weight: .regular))
.foregroundStyle(.white.opacity(0.9)) .foregroundStyle(.white)
.frame(maxWidth: .infinity) }
Button { isRepeating.toggle() } label: {
Image(systemName: "repeat")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(isRepeating ? .white : .white.opacity(0.45))
} }
.buttonStyle(.plain)
} }
.padding(.horizontal, 20) .padding(.horizontal, 20)
.padding(.top, 32) .padding(.top, 18)
// Speed + Auto-next // Utility row
HStack(spacing: 20) { HStack(spacing: 30) {
Menu { Button {} label: { Image(systemName: "airplayaudio") }
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], id: \.self) { s in Button {} label: { Image(systemName: "gearshape") }
Button { Button { onDismiss() } label: { Image(systemName: "chevron.down") }
audioPlayer.setSpeed(s) Button {} label: { Image(systemName: "quote.bubble") }
} label: { Button {} label: { Image(systemName: "list.bullet") }
if s == audioPlayer.speed { Button {} label: { Image(systemName: "moon.zzz") }
Label("\(s, specifier: "%.2g")×", systemImage: "checkmark")
} else {
Text("\(s, specifier: "%.2g")×")
}
}
}
} label: {
Text("\(audioPlayer.speed, specifier: "%.2g")×")
.font(.subheadline.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.white.opacity(0.18), in: Capsule())
}
Spacer()
// Auto-next toggle
Button {
audioPlayer.autoNext.toggle()
} label: {
HStack(spacing: 6) {
Image(systemName: "forward.end")
.font(.system(size: 13, weight: .semibold))
Text("Auto-next")
.font(.subheadline.weight(.semibold))
}
.foregroundStyle(audioPlayer.autoNext ? Color.amber : .white.opacity(0.55))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(
audioPlayer.autoNext
? Color.amber.opacity(0.18)
: Color.white.opacity(0.1),
in: Capsule()
)
.overlay(
Capsule()
.stroke(
audioPlayer.autoNext ? Color.amber.opacity(0.5) : Color.clear,
lineWidth: 1
)
)
}
.buttonStyle(.plain)
} }
.padding(.horizontal, 28) .font(.system(size: 18, weight: .semibold))
.padding(.top, 28) .foregroundStyle(.white.opacity(0.75))
.padding(.top, 24)
Spacer(minLength: 32) Spacer(minLength: 26)
} }
} }
} }