Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/AppRouter/AppDependencyBootstrap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ import Foundation
let decodeEffect = DecodeEffect(duration: 0)
trackChange = Just(fixture.trackUpdate).eraseToAnyPublisher()
artwork = Just(nil).eraseToAnyPublisher()
playbackPosition = Just(PlaybackPosition(elapsed: nil, playbackRate: 0)).eraseToAnyPublisher()
playbackPosition = Just(PlaybackPosition(rawElapsed: nil, timestamp: nil, playbackRate: 0)).eraseToAnyPublisher()
decodeEffectConfig = decodeEffect
textLayout = TextLayout(decodeEffect: decodeEffect)
artworkStyle = ArtworkStyle(opacity: 0)
Expand Down
13 changes: 10 additions & 3 deletions Sources/Entity/PlaybackPosition.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import Foundation

public struct PlaybackPosition {
public let elapsed: TimeInterval?
public let rawElapsed: TimeInterval?
public let timestamp: Date?
public let playbackRate: Double

public init(elapsed: TimeInterval? = nil, playbackRate: Double = 1.0) {
self.elapsed = elapsed
public init(
rawElapsed: TimeInterval? = nil,
timestamp: Date? = nil,
playbackRate: Double = 1.0
) {
self.rawElapsed = rawElapsed
self.timestamp = timestamp
self.playbackRate = playbackRate
}
}

extension PlaybackPosition: Sendable {}
extension PlaybackPosition: Equatable {}
16 changes: 11 additions & 5 deletions Sources/MediaRemoteDataSource/Resources/media-remote-helper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ let register = unsafeBitCast(regSym, to: RegisterFn.self)
r["duration"] = d["kMRMediaRemoteNowPlayingInfoDuration"]
r["elapsed"] = d["kMRMediaRemoteNowPlayingInfoElapsedTime"]
r["rate"] = d["kMRMediaRemoteNowPlayingInfoPlaybackRate"]
if let ts = d["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date {
r["timestamp"] = ts.timeIntervalSinceReferenceDate
}
// MediaRemote sometimes omits the timestamp field; synthesize it from
// the current fetch moment so the client can always interpolate elapsed
// between polls (otherwise lyric highlighting would only advance once
// per polling interval for timestamp-less sources).
let ts = (d["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date) ?? Date()
r["timestamp"] = ts.timeIntervalSinceReferenceDate
if let art = d["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data {
r["artwork_base64"] = art.base64EncodedString()
}
Expand All @@ -57,8 +60,11 @@ for name in [
) { _ in fetchAndPrint() }
}

// Periodic fallback for elapsed time updates (needed for lyric sync)
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in fetchAndPrint() }
// Periodic fallback for snapshot refresh (rawElapsed/timestamp/playbackRate).
// The client (LyricsPresenter) interpolates elapsed on every DisplayLink tick
// from this snapshot, so 3s polling is sufficient for lyric sync. pause/seek
// is delivered immediately via `kMRMediaRemoteNowPlayingInfoDidChangeNotification`.
Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { _ in fetchAndPrint() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep frequent polling when timestamps are unavailable

With MediaRemote sources that omit kMRMediaRemoteNowPlayingInfoTimestamp, the presenter explicitly falls back to rawElapsed without interpolation (LyricsPresenter.interpolatedElapsed returns base when the timestamp is nil), so increasing the helper fallback to 3 seconds makes lyric highlighting update only once every 3 seconds for those sources. The previous 1-second fallback was the only mechanism advancing elapsed time in this scenario; either keep a shorter interval for timestamp-less snapshots or synthesize/update a timestamp before relying on the 3-second poll.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

action

指摘の懸念に対し、helper 側で fetch 時刻を timestamp として注入する形に修正しました (2b7f7d9)。

kMRMediaRemoteNowPlayingInfoTimestamp欠落した snapshot では Date() を代替値として使用し、client 側の補間が常に有効になるようにしています。これにより timestamp-less ソースでも 3 秒 polling 間隔のままフレーム単位精度を維持できます。

playbackRate == 0 (pause) 時は LyricsPresenter 側で補間自体をスキップする (updateActiveLineTick のガード) ため、停止中に誤って前進する副作用はありません。


// Initial fetch
fetchAndPrint()
Expand Down
18 changes: 15 additions & 3 deletions Sources/Presenters/Track/LyricsPresenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ public final class LyricsPresenter: ObservableObject {

private var lyricEffects: [DecodeEffectState] = []
private var decodeConfig: DecodeEffect?
private var latestElapsed: TimeInterval?
private var latestRawElapsed: TimeInterval?
private var latestTimestamp: Date?
private var latestPlaybackRate: Double = 1.0
private var cancellables: Set<AnyCancellable> = []

@Dependency(\.trackInteractor) private var interactor
@Dependency(\.date.now) private var now

public init() {}

Expand All @@ -38,7 +40,8 @@ public final class LyricsPresenter: ObservableObject {
interactor.playbackPosition
.receive(on: DispatchQueue.main)
.sink { [weak self] position in
self?.latestElapsed = position.elapsed
self?.latestRawElapsed = position.rawElapsed
self?.latestTimestamp = position.timestamp
self?.latestPlaybackRate = position.playbackRate
}
.store(in: &cancellables)
Expand Down Expand Up @@ -101,13 +104,22 @@ public final class LyricsPresenter: ObservableObject {
}

/// Called from DisplayLink to keep activeLineIndex in sync at frame rate.
/// Interpolates elapsed time on every tick from the latest snapshot
/// (rawElapsed + timestamp + playbackRate), so the helper can poll at
/// a longer interval without introducing lyric-highlight lag.
public func updateActiveLineTick() {
guard case .success(.timed(let lines)) = lyricsState else { return }
guard latestPlaybackRate != 0 else { return }
let index = latestElapsed.flatMap { elapsed in lines.lastIndex { $0.time <= elapsed } }
let index = interpolatedElapsed.flatMap { elapsed in lines.lastIndex { $0.time <= elapsed } }
guard index != activeLineIndex else { return }
activeLineIndex = index
}

private var interpolatedElapsed: TimeInterval? {
guard let base = latestRawElapsed else { return nil }
guard let ts = latestTimestamp else { return base }
return base + latestPlaybackRate * now.timeIntervalSince(ts)
}
}

extension LyricsPresenter {
Expand Down
5 changes: 4 additions & 1 deletion Sources/TrackInteractor/TrackInteractorImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ public final class TrackInteractorImpl: @unchecked Sendable {

public lazy var playbackPosition: AnyPublisher<PlaybackPosition, Never> =
activeNowPlaying
.map { [playbackService] np in PlaybackPosition(elapsed: playbackService.elapsedTime(for: np), playbackRate: np.playbackRate) }
.map { np in
PlaybackPosition(
rawElapsed: np.rawElapsed, timestamp: np.timestamp, playbackRate: np.playbackRate)
}
.eraseToAnyPublisher()

public init() {
Expand Down
2 changes: 1 addition & 1 deletion Sources/VersionHandler/Resources/version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.13.6
2.13.7
28 changes: 17 additions & 11 deletions Tests/PresentersTests/LyricsPresenterDuplicateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ struct LyricsPresenterDuplicateTests {
#expect(presenter.lyricsState == .success(content))

// Send playback position at 6 seconds (should highlight "Second")
positionSubject.send(PlaybackPosition(elapsed: 6.0, playbackRate: 1.0))
positionSubject.send(PlaybackPosition(rawElapsed: 6.0, playbackRate: 1.0))
var deadline = ContinuousClock.now + .seconds(3)
while ContinuousClock.now < deadline {
presenter.updateActiveLineTick()
Expand All @@ -120,7 +120,7 @@ struct LyricsPresenterDuplicateTests {
#expect(presenter.lyricsState == .success(content))

// Advance to 11 seconds (should highlight "Third")
positionSubject.send(PlaybackPosition(elapsed: 11.0, playbackRate: 1.0))
positionSubject.send(PlaybackPosition(rawElapsed: 11.0, playbackRate: 1.0))
deadline = ContinuousClock.now + .seconds(3)
while ContinuousClock.now < deadline {
presenter.updateActiveLineTick()
Expand Down Expand Up @@ -157,17 +157,23 @@ struct LyricsPresenterDuplicateTests {
await waitForLyricsSuccess(presenter)

// Set position while playing
positionSubject.send(PlaybackPosition(elapsed: 6.0, playbackRate: 1.0))
try? await Task.sleep(for: .milliseconds(200))
presenter.updateActiveLineTick()
positionSubject.send(PlaybackPosition(rawElapsed: 6.0, playbackRate: 1.0))
var deadline = ContinuousClock.now + .seconds(3)
while ContinuousClock.now < deadline {
presenter.updateActiveLineTick()
if presenter.activeLineIndex == 1 { break }
try? await Task.sleep(for: .milliseconds(10))
}
#expect(presenter.activeLineIndex == 1)

// Pause (rate = 0), send new position
positionSubject.send(PlaybackPosition(elapsed: 6.0, playbackRate: 0))
try? await Task.sleep(for: .milliseconds(200))
presenter.updateActiveLineTick()

// activeLineIndex should not update when paused
// Pause (rate = 0), send new position — paused guard should keep index at 1
positionSubject.send(PlaybackPosition(rawElapsed: 6.0, playbackRate: 0))
deadline = ContinuousClock.now + .seconds(1)
while ContinuousClock.now < deadline {
presenter.updateActiveLineTick()
if presenter.activeLineIndex != 1 { break }
try? await Task.sleep(for: .milliseconds(10))
}
#expect(presenter.activeLineIndex == 1)
}
}
Expand Down
115 changes: 104 additions & 11 deletions Tests/PresentersTests/LyricsPresenterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,20 @@ struct LyricsPresenterTests {
trackSubject.send(TrackUpdate(lyrics: content, lyricsState: .resolved))
await waitForLyricsSuccess(presenter)

// Send a paused playback position (rate = 0)
positionSubject.send(PlaybackPosition(elapsed: 6, playbackRate: 0))
// Allow Combine to deliver the position update
try? await Task.sleep(for: .milliseconds(50))

presenter.updateActiveLineTick()
// activeLineIndex should remain nil because playback is paused
// Send a paused playback position (rate = 0).
positionSubject.send(PlaybackPosition(rawElapsed: 6, playbackRate: 0))

// activeLineIndex must remain nil — both before sink delivery
// (latestRawElapsed still nil → interpolatedElapsed nil → no index
// change) and after delivery (latestPlaybackRate == 0 → early
// return). Poll the tick to confirm it never flips during a
// short observation window.
let deadline = ContinuousClock.now + .seconds(1)
while ContinuousClock.now < deadline {
presenter.updateActiveLineTick()
if presenter.activeLineIndex != nil { break }
try? await Task.sleep(for: .milliseconds(10))
}
#expect(presenter.activeLineIndex == nil)
}
}
Expand Down Expand Up @@ -229,11 +236,97 @@ struct LyricsPresenterTests {
await waitForLyricsSuccess(presenter)

// Send position at 6s — should highlight Line B (time=5)
positionSubject.send(PlaybackPosition(elapsed: 6, playbackRate: 1.0))
// Allow Combine to deliver the position update
try? await Task.sleep(for: .milliseconds(50))
positionSubject.send(PlaybackPosition(rawElapsed: 6, playbackRate: 1.0))

Comment on lines 238 to 240

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

action

deadline polling に置換しました (78acf8b)。3 秒以内に activeLineIndex == 1到達すれば即 break する形で、Combine 配信の遅延に強くしています。

let deadline = ContinuousClock.now + .seconds(3)
while ContinuousClock.now < deadline {
presenter.updateActiveLineTick()
if presenter.activeLineIndex == 1 { break }
try? await Task.sleep(for: .milliseconds(10))
}
#expect(presenter.activeLineIndex == 1)
}
}

@MainActor
@Test("interpolates elapsed from snapshot (rawElapsed + timestamp + playbackRate)")
func interpolatesFromSnapshot() async throws {
let fixedNow = Date(timeIntervalSinceReferenceDate: 1_000_000)
let trackSubject = PassthroughSubject<TrackUpdate, Never>()
let positionSubject = PassthroughSubject<PlaybackPosition, Never>()
let lines: [LyricLine] = [
.init(time: 0, text: "Line A"),
.init(time: 5, text: "Line B"),
.init(time: 10, text: "Line C"),
]
let content = LyricsContent.timed(lines)

await withDependencies {
$0.trackInteractor = StubTrackInteractor(
trackChangePublisher: trackSubject.eraseToAnyPublisher(),
playbackPositionPublisher: positionSubject.eraseToAnyPublisher(),
textLayout: TextLayout(decodeEffect: .init(duration: 0))
)
$0.date = .constant(fixedNow)
} operation: {
let presenter = LyricsPresenter()
presenter.start()

trackSubject.send(TrackUpdate(lyrics: content, lyricsState: .resolved))
await waitForLyricsSuccess(presenter)

presenter.updateActiveLineTick()
// Snapshot from 7s ago: rawElapsed=0, rate=1.0
// Interpolated at fixedNow: 0 + 1.0 * 7.0 = 7.0 → Line B (time=5)
positionSubject.send(
PlaybackPosition(
rawElapsed: 0,
timestamp: fixedNow.addingTimeInterval(-7),
playbackRate: 1.0))

let deadline = ContinuousClock.now + .seconds(3)
while ContinuousClock.now < deadline {
presenter.updateActiveLineTick()
if presenter.activeLineIndex == 1 { break }
try? await Task.sleep(for: .milliseconds(10))
}
#expect(presenter.activeLineIndex == 1)
}
}

@MainActor
@Test("falls back to rawElapsed when timestamp is nil")
func fallsBackWhenTimestampMissing() async throws {
let trackSubject = PassthroughSubject<TrackUpdate, Never>()
let positionSubject = PassthroughSubject<PlaybackPosition, Never>()
let lines: [LyricLine] = [
.init(time: 0, text: "A"),
.init(time: 5, text: "B"),
.init(time: 10, text: "C"),
]
let content = LyricsContent.timed(lines)

await withDependencies {
$0.trackInteractor = StubTrackInteractor(
trackChangePublisher: trackSubject.eraseToAnyPublisher(),
playbackPositionPublisher: positionSubject.eraseToAnyPublisher(),
textLayout: TextLayout(decodeEffect: .init(duration: 0))
)
} operation: {
let presenter = LyricsPresenter()
presenter.start()

trackSubject.send(TrackUpdate(lyrics: content, lyricsState: .resolved))
await waitForLyricsSuccess(presenter)

// timestamp nil → rawElapsed used verbatim (no interpolation)
positionSubject.send(PlaybackPosition(rawElapsed: 7, timestamp: nil, playbackRate: 1.0))

let deadline = ContinuousClock.now + .seconds(3)
while ContinuousClock.now < deadline {
presenter.updateActiveLineTick()
if presenter.activeLineIndex == 1 { break }
try? await Task.sleep(for: .milliseconds(10))
}
#expect(presenter.activeLineIndex == 1)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Expand Down
Loading
Loading