Version 1.4 - Translations, Like toasts Queue redesign.
This commit is contained in:
+282
-40
@@ -14,12 +14,21 @@ struct PlayerNowPlayingView: View {
|
||||
|
||||
@State private var localVolume: Double = 0
|
||||
@State private var isVolumeEditing = false
|
||||
@State private var volumeSettleTask: Task<Void, Never>?
|
||||
@State private var isMuted = false
|
||||
@State private var preMuteVolume: Double = 50
|
||||
@State private var showQueue = false
|
||||
@State private var displayedElapsed: Double = 0
|
||||
@State private var isProgressEditing = false
|
||||
@State private var progressSettleTask: Task<Void, Never>?
|
||||
@State private var progressTimer: Timer?
|
||||
|
||||
// Queue scroll trigger
|
||||
@State private var scrollToQueue = false
|
||||
|
||||
// Queue state
|
||||
@State private var isQueueLoading = false
|
||||
@State private var showClearConfirm = false
|
||||
|
||||
// Auto-tracks live updates via @Observable
|
||||
private var player: MAPlayer? {
|
||||
service.playerManager.players[playerId]
|
||||
@@ -41,28 +50,62 @@ struct PlayerNowPlayingView: View {
|
||||
Double(currentItem?.duration ?? 0)
|
||||
}
|
||||
|
||||
// Queue computed properties
|
||||
private var queueItems: [MAQueueItem] {
|
||||
service.playerManager.queues[playerId] ?? []
|
||||
}
|
||||
|
||||
private var currentQueueIndex: Int? {
|
||||
service.playerManager.playerQueues[playerId]?.currentIndex
|
||||
}
|
||||
|
||||
private var currentItemId: String? {
|
||||
service.playerManager.playerQueues[playerId]?.currentItem?.queueItemId
|
||||
}
|
||||
|
||||
private var shuffleEnabled: Bool {
|
||||
service.playerManager.playerQueues[playerId]?.shuffleEnabled ?? false
|
||||
}
|
||||
|
||||
private var repeatMode: RepeatMode {
|
||||
service.playerManager.playerQueues[playerId]?.repeatMode ?? .off
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
// Pinned header — always visible
|
||||
headerView
|
||||
|
||||
// Conditional content area
|
||||
if showQueue {
|
||||
PlayerQueueView(playerId: playerId)
|
||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||
} else {
|
||||
playerContent
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
// Scrollable content (album art + queue only)
|
||||
// GeometryReader gives the actual available height so playerContent
|
||||
// can fill exactly (viewport − "Up Next" header), making only the
|
||||
// caption peek at the bottom as a scroll hint.
|
||||
GeometryReader { geo in
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView(.vertical, showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
playerContent
|
||||
// Leave ~48 pt so only the "Up Next" header peeks
|
||||
.frame(minHeight: geo.size.height - 48)
|
||||
|
||||
// Queue section — below the fold
|
||||
queueSection
|
||||
.id("queueSection")
|
||||
}
|
||||
}
|
||||
.onChange(of: scrollToQueue) { _, shouldScroll in
|
||||
if shouldScroll {
|
||||
withAnimation(.easeInOut(duration: 0.4)) {
|
||||
proxy.scrollTo("queueSection", anchor: .top)
|
||||
}
|
||||
scrollToQueue = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// Progress bar (only in player mode, not queue)
|
||||
if !showQueue {
|
||||
progressView
|
||||
}
|
||||
|
||||
// Transport + volume (always visible)
|
||||
// Pinned controls — always outside the scroll view, no gesture conflicts
|
||||
progressView
|
||||
controlsView
|
||||
}
|
||||
.background {
|
||||
@@ -103,10 +146,9 @@ struct PlayerNowPlayingView: View {
|
||||
progressTimer = nil
|
||||
}
|
||||
.onChange(of: playerQueue?.elapsedTime) {
|
||||
syncElapsedTime()
|
||||
if !isProgressEditing { syncElapsedTime() }
|
||||
}
|
||||
.onChange(of: player?.state) {
|
||||
// Restart timer when play state changes
|
||||
syncElapsedTime()
|
||||
if player?.state == .playing {
|
||||
startProgressTimer()
|
||||
@@ -115,6 +157,17 @@ struct PlayerNowPlayingView: View {
|
||||
progressTimer = nil
|
||||
}
|
||||
}
|
||||
.task {
|
||||
isQueueLoading = true
|
||||
try? await service.playerManager.loadQueue(playerId: playerId)
|
||||
isQueueLoading = false
|
||||
}
|
||||
.confirmationDialog("Clear the entire queue?", isPresented: $showClearConfirm, titleVisibility: .visible) {
|
||||
Button("Clear Queue", role: .destructive) {
|
||||
Task { try? await service.playerManager.clearQueue(playerId: playerId) }
|
||||
}
|
||||
Button("Cancel", role: .cancel) { }
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.hidden)
|
||||
}
|
||||
@@ -143,7 +196,7 @@ struct PlayerNowPlayingView: View {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Text(showQueue ? "Up Next" : "Now Playing")
|
||||
Text("Now Playing")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(player?.name ?? "")
|
||||
@@ -155,21 +208,20 @@ struct PlayerNowPlayingView: View {
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
// Queue icon — scrolls to the queue section below
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
showQueue.toggle()
|
||||
}
|
||||
scrollToQueue = true
|
||||
} label: {
|
||||
Image(systemName: "list.bullet")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(showQueue ? Color.accentColor : .primary)
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
if !showQueue, let uri = mediaItem?.uri {
|
||||
FavoriteButton(uri: uri, size: 22)
|
||||
if let uri = mediaItem?.uri {
|
||||
FavoriteButton(uri: uri, size: 22, itemName: currentItem?.name)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
}
|
||||
@@ -230,6 +282,7 @@ struct PlayerNowPlayingView: View {
|
||||
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
// MARK: - Transport + Volume Controls
|
||||
@@ -291,17 +344,46 @@ struct PlayerNowPlayingView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Slider(value: $localVolume, in: 0...100, step: 1) { editing in
|
||||
isVolumeEditing = editing
|
||||
if !editing {
|
||||
Task {
|
||||
try? await service.playerManager.setVolume(
|
||||
playerId: playerId,
|
||||
level: Int(localVolume)
|
||||
)
|
||||
}
|
||||
GeometryReader { geo in
|
||||
let thumbX = max(0, min(geo.size.width, geo.size.width * localVolume / 100))
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(.primary.opacity(0.15))
|
||||
.frame(height: 6)
|
||||
Capsule()
|
||||
.fill(.primary)
|
||||
.frame(width: thumbX, height: 6)
|
||||
Circle()
|
||||
.fill(.primary)
|
||||
.frame(width: 14, height: 14)
|
||||
.offset(x: thumbX - 7)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
isVolumeEditing = true
|
||||
localVolume = max(0, min(100, value.location.x / geo.size.width * 100))
|
||||
}
|
||||
.onEnded { value in
|
||||
localVolume = max(0, min(100, value.location.x / geo.size.width * 100))
|
||||
Task {
|
||||
try? await service.playerManager.setVolume(
|
||||
playerId: playerId,
|
||||
level: Int(localVolume)
|
||||
)
|
||||
}
|
||||
volumeSettleTask?.cancel()
|
||||
volumeSettleTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
guard !Task.isCancelled else { return }
|
||||
isVolumeEditing = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: 28)
|
||||
|
||||
Button { adjustVolume(by: 5) } label: {
|
||||
Image(systemName: "speaker.wave.3.fill")
|
||||
@@ -325,17 +407,51 @@ struct PlayerNowPlayingView: View {
|
||||
VStack(spacing: 4) {
|
||||
// Progress bar
|
||||
GeometryReader { geo in
|
||||
let thumbX = max(0, min(geo.size.width, geo.size.width * progress))
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(.primary.opacity(0.15))
|
||||
.frame(height: 4)
|
||||
|
||||
Capsule()
|
||||
.fill(.primary)
|
||||
.frame(width: geo.size.width * progress, height: 4)
|
||||
.frame(width: thumbX, height: 4)
|
||||
Circle()
|
||||
.fill(.primary)
|
||||
.frame(width: 14, height: 14)
|
||||
.offset(x: thumbX - 7)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
guard duration > 0 else { return }
|
||||
isProgressEditing = true
|
||||
progressTimer?.invalidate()
|
||||
displayedElapsed = max(0, min(duration, value.location.x / geo.size.width * duration))
|
||||
}
|
||||
.onEnded { value in
|
||||
guard duration > 0 else { return }
|
||||
let seekTo = max(0, min(duration, value.location.x / geo.size.width * duration))
|
||||
displayedElapsed = seekTo
|
||||
Task {
|
||||
do {
|
||||
try await service.playerManager.seek(playerId: playerId, position: seekTo)
|
||||
} catch {
|
||||
print("❌ Seek failed: \(error)")
|
||||
}
|
||||
}
|
||||
progressSettleTask?.cancel()
|
||||
progressSettleTask = Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
guard !Task.isCancelled else { return }
|
||||
isProgressEditing = false
|
||||
startProgressTimer()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: 4)
|
||||
.frame(height: 28)
|
||||
|
||||
// Time labels
|
||||
HStack {
|
||||
@@ -354,6 +470,133 @@ struct PlayerNowPlayingView: View {
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
// MARK: - Queue Section
|
||||
|
||||
@ViewBuilder
|
||||
private var queueSection: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Section header
|
||||
HStack {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chevron.up")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Up Next")
|
||||
.font(.headline)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
Spacer()
|
||||
if !queueItems.isEmpty {
|
||||
Text("\(queueItems.count) tracks")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Control bar (shuffle / repeat / clear)
|
||||
queueControlBar
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
Divider()
|
||||
|
||||
// Queue items
|
||||
if isQueueLoading && queueItems.isEmpty {
|
||||
ProgressView()
|
||||
.frame(height: 100)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else if queueItems.isEmpty {
|
||||
Text("Queue is empty")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(height: 100)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(queueItems.enumerated()), id: \.element.id) { index, item in
|
||||
let isCurrent = currentQueueIndex == index || item.queueItemId == currentItemId
|
||||
QueueItemRow(item: item, isCurrent: isCurrent)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
Task {
|
||||
try? await service.playerManager.playIndex(
|
||||
playerId: playerId,
|
||||
index: index
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Queue Control Bar
|
||||
|
||||
@ViewBuilder
|
||||
private var queueControlBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Shuffle
|
||||
Button {
|
||||
Task { try? await service.playerManager.setShuffle(playerId: playerId, enabled: !shuffleEnabled) }
|
||||
} label: {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: "shuffle")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(shuffleEnabled ? Color.accentColor : .secondary)
|
||||
Text("Shuffle")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(shuffleEnabled ? Color.accentColor : .secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Repeat
|
||||
Button {
|
||||
let next: RepeatMode
|
||||
switch repeatMode {
|
||||
case .off: next = .all
|
||||
case .all: next = .one
|
||||
case .one: next = .off
|
||||
}
|
||||
Task { try? await service.playerManager.setRepeatMode(playerId: playerId, mode: next) }
|
||||
} label: {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: repeatMode == .one ? "repeat.1" : "repeat")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(repeatMode == .off ? .secondary : Color.accentColor)
|
||||
Text(repeatMode == .off ? "Repeat" : (repeatMode == .one ? "Repeat 1" : "Repeat All"))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(repeatMode == .off ? .secondary : Color.accentColor)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Clear queue
|
||||
Button {
|
||||
showClearConfirm = true
|
||||
} label: {
|
||||
VStack(spacing: 3) {
|
||||
Image(systemName: "xmark.bin")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Clear")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(queueItems.isEmpty)
|
||||
.opacity(queueItems.isEmpty ? 0.4 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress Helpers
|
||||
|
||||
private func syncElapsedTime() {
|
||||
@@ -378,9 +621,8 @@ struct PlayerNowPlayingView: View {
|
||||
guard player?.state == .playing else { return }
|
||||
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
||||
Task { @MainActor in
|
||||
guard player?.state == .playing else { return }
|
||||
guard player?.state == .playing, !isProgressEditing else { return }
|
||||
displayedElapsed += 0.5
|
||||
// Clamp to duration
|
||||
if trackDuration > 0 {
|
||||
displayedElapsed = min(displayedElapsed, trackDuration)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user