diff --git a/App/Continuity/Views/MiniPlayerView.swift b/App/Continuity/Views/MiniPlayerView.swift index 630d2f8..0dd31cd 100644 --- a/App/Continuity/Views/MiniPlayerView.swift +++ b/App/Continuity/Views/MiniPlayerView.swift @@ -52,7 +52,8 @@ private struct MiniProgressLine: View { @Environment(Player.self) private var player var body: some View { - let progress = player.duration > 0 ? min(max(player.position / player.duration, 0), 1) : 0 + // displayProgress morphs across blends, so the line glides into the next song. + let progress = player.displayProgress GeometryReader { geo in Capsule() .fill(Color.accentColor) diff --git a/App/Continuity/Views/NowPlayingView.swift b/App/Continuity/Views/NowPlayingView.swift index 6a348cc..9a900a0 100644 --- a/App/Continuity/Views/NowPlayingView.swift +++ b/App/Continuity/Views/NowPlayingView.swift @@ -427,7 +427,8 @@ private struct TrackProgressRing: View { @Environment(Player.self) private var player var body: some View { - let progress = player.duration > 0 ? min(max(player.position / player.duration, 0), 1) : 0 + // displayProgress morphs across blends, so the ring glides into the next song. + let progress = player.displayProgress Circle() .trim(from: 0, to: progress) .stroke(.white, style: StrokeStyle(lineWidth: 3, lineCap: .round)) diff --git a/Packages/ContinuityKit/Sources/Playback/Player.swift b/Packages/ContinuityKit/Sources/Playback/Player.swift index 8e2fe55..6bc4c7d 100644 --- a/Packages/ContinuityKit/Sources/Playback/Player.swift +++ b/Packages/ContinuityKit/Sources/Playback/Player.swift @@ -79,6 +79,24 @@ public final class Player { return currentTrack?.durationSeconds ?? 0 } + /// Progress fraction (0…1) for UI rings/bars. During a blend it MORPHS from the outgoing + /// track's fraction toward the incoming track's, weighted by the blend progress — by the + /// moment the decks swap, the displayed value already equals the incoming fraction, so the + /// indicator glides into the next song instead of snapping to it. + public var displayProgress: Double { + let current = duration > 0 ? min(max(position / duration, 0), 1) : 0 + guard isTransitioning, let audio, + queue.indices.contains(transitionTargetIndex) else { return current } + let incoming = queue[transitionTargetIndex] + let incomingDuration = audio.idle.loadedDuration > 0 + ? audio.idle.loadedDuration : incoming.durationSeconds + guard incomingDuration > 0 else { return current } + let incomingPosition = incomingStartOffset + audio.idle.elapsed + let incomingFraction = min(max(incomingPosition / incomingDuration, 0), 1) + let weight = min(max(transitionProgress, 0), 1) + return current + (incomingFraction - current) * weight + } + // MARK: Engine / decks /// All CoreAudio state, or nil until the first real audio need (see `ensureAudioStack()`). var audio: AudioStack?