Version 1.4 - Translations, Like toasts Queue redesign.

This commit is contained in:
2026-04-09 16:54:41 +02:00
parent ec1ffcb0b1
commit 5f3902cb54
30 changed files with 3472 additions and 654 deletions
+282 -40
View File
@@ -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)
}