mirror of
https://github.com/yattee/yattee.git
synced 2026-10-29 16:07:20 +00:00
Every mpv time-pos tick updated PlayerState.currentTime and bufferedTime, re-rendering the invisible player controls, progress bar and chapter segments several times per second while the app played in the background, which drained the battery during long audio-only sessions. - PlayerState keeps currentTime/bufferedTime current for readers but stops notifying observers while publishesTimeUpdates is off; PlayerService turns it off on entering the background and back on (with one catch-up notification) before returning to the foreground - MarqueeText pauses its animation timeline outside the active scene - Replace the 100 ms polling loops in RemoteControlCoordinator and ToastManager (iOS/tvOS) with observation-driven waits Main-thread samples during 30 s of background audio-only playback on an iPhone 12 mini went from 1289 to 248.
204 lines
5.5 KiB
Swift
204 lines
5.5 KiB
Swift
//
|
|
// MarqueeText.swift
|
|
// Yattee
|
|
//
|
|
// Scrolling text for long content that doesn't fit in container.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct MarqueeText: View {
|
|
let text: String
|
|
var font: Font = .subheadline
|
|
var fontWeight: Font.Weight = .medium
|
|
var foregroundStyle: Color = .primary
|
|
var velocity: CGFloat = 30
|
|
var spacing: CGFloat = 50
|
|
var delayBeforeScrolling: Double = 3.0
|
|
|
|
// MARK: - State
|
|
|
|
@State private var containerWidth: CGFloat = 0
|
|
@State private var textWidth: CGFloat = 0
|
|
@State private var startTime: Date?
|
|
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
private var needsScrolling: Bool {
|
|
textWidth > containerWidth && containerWidth > 0
|
|
}
|
|
|
|
private var totalScrollDistance: CGFloat {
|
|
textWidth + spacing
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
Group {
|
|
if needsScrolling {
|
|
scrollingContent
|
|
} else {
|
|
staticContent
|
|
}
|
|
}
|
|
.onChange(of: geometry.size.width) { _, newWidth in
|
|
containerWidth = newWidth
|
|
startTime = .now
|
|
}
|
|
.onAppear {
|
|
containerWidth = geometry.size.width
|
|
}
|
|
}
|
|
.frame(height: textHeight)
|
|
.clipped()
|
|
.onChange(of: text) {
|
|
startTime = .now
|
|
}
|
|
}
|
|
|
|
// MARK: - Static Content
|
|
|
|
private var staticContent: some View {
|
|
textView
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
// MARK: - Scrolling Content
|
|
|
|
private var scrollingContent: some View {
|
|
// Paused outside the active scene: the timeline otherwise keeps
|
|
// re-rendering every frame while the app plays in the background.
|
|
TimelineView(.animation(paused: scenePhase != .active)) { context in
|
|
HStack(spacing: spacing) {
|
|
textView
|
|
textView
|
|
}
|
|
.offset(x: calculateOffset(at: context.date))
|
|
.onAppear {
|
|
if startTime == nil {
|
|
startTime = context.date
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Text View
|
|
|
|
private var textView: some View {
|
|
Text(text)
|
|
.font(font.weight(fontWeight))
|
|
.foregroundStyle(foregroundStyle)
|
|
.lineLimit(1)
|
|
.fixedSize()
|
|
.measureWidth { width in
|
|
if textWidth != width {
|
|
textWidth = width
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Text Height
|
|
|
|
private var textHeight: CGFloat {
|
|
#if os(macOS)
|
|
let nsFont = NSFont.systemFont(ofSize: NSFont.systemFontSize)
|
|
return nsFont.boundingRectForFont.height
|
|
#else
|
|
let uiFont = UIFont.preferredFont(forTextStyle: .subheadline)
|
|
return uiFont.lineHeight
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Offset Calculation
|
|
|
|
private func calculateOffset(at date: Date) -> CGFloat {
|
|
guard let startTime else { return 0 }
|
|
|
|
let elapsed = date.timeIntervalSince(startTime)
|
|
|
|
// During initial delay period, stay at start position
|
|
guard elapsed >= delayBeforeScrolling else { return 0 }
|
|
|
|
// Calculate cycle timing
|
|
let scrollDuration = totalScrollDistance / velocity
|
|
let cycleDuration = scrollDuration + delayBeforeScrolling
|
|
|
|
// Determine position within current cycle
|
|
let scrollElapsed = elapsed - delayBeforeScrolling
|
|
let cyclePosition = scrollElapsed.truncatingRemainder(dividingBy: cycleDuration)
|
|
|
|
// Pause phase - stay at start position
|
|
if cyclePosition >= scrollDuration {
|
|
return 0
|
|
}
|
|
|
|
// Scroll phase
|
|
return -(cyclePosition * velocity)
|
|
}
|
|
}
|
|
|
|
// MARK: - Width Measurement Extension
|
|
|
|
private extension View {
|
|
func measureWidth(_ onChange: @escaping (CGFloat) -> Void) -> some View {
|
|
background {
|
|
GeometryReader { proxy in
|
|
Color.clear
|
|
.onAppear {
|
|
onChange(proxy.size.width)
|
|
}
|
|
.onChange(of: proxy.size.width) { _, newWidth in
|
|
onChange(newWidth)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
VStack(spacing: 20) {
|
|
Text("Short text (no scroll):")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
MarqueeText(text: "Short text")
|
|
.frame(width: 200)
|
|
.border(Color.gray)
|
|
|
|
Text("Long text (scrolls after 3s):")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
MarqueeText(text: "This is a very long text that will scroll horizontally in a marquee style animation")
|
|
.frame(width: 200)
|
|
.border(Color.gray)
|
|
|
|
Text("Custom styled (faster velocity):")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
MarqueeText(
|
|
text: "Custom styled marquee text with different settings and faster scroll speed",
|
|
font: .caption,
|
|
foregroundStyle: .secondary,
|
|
velocity: 50
|
|
)
|
|
.frame(width: 150)
|
|
.border(Color.gray)
|
|
|
|
Text("Shorter delay (1s):")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
MarqueeText(
|
|
text: "This text starts scrolling after just one second delay",
|
|
delayBeforeScrolling: 1.0
|
|
)
|
|
.frame(width: 200)
|
|
.border(Color.gray)
|
|
}
|
|
.padding()
|
|
}
|