luminate-old/Sources/LuminateUI/Components/SeekBar.swift

145 lines
4.4 KiB
Swift

//
// SeekBar.swift
//
// Copyright 2026 Brendan Szymanski <hello@bscubed.dev>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// SPDX-License-Identifier: GPL-3.0-or-later
//
import Adwaita
import CAdw
import LuminateCore
/// A seekable progress slider wrapping `GtkScale`.
///
/// Use for media playback position scrubbing. Emits the new value
/// via the `onSeek` callback when the user drags the slider.
///
/// ```swift
/// SeekBar(value: $position, range: 0...duration, onSeek: { pos in
/// player.seek(to: pos)
/// })
/// ```
public struct SeekBar: Widget {
#if exposeGeneratedAppearUpdateFunctions
public var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
public var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
#else
var updateFunctions: [(ViewStorage, WidgetData, Bool) -> Void] = []
var appearFunctions: [(ViewStorage, WidgetData) -> Void] = []
#endif
@Binding var value: Double
var range: ClosedRange<Double>
var playbackState: PlayerPlaybackState?
var onSeek: ((Double) -> Void)?
public init(
value: Binding<Double>,
range: ClosedRange<Double>,
playbackState: PlayerPlaybackState? = nil,
onSeek: ((Double) -> Void)? = nil
) {
self._value = value
self.range = range
self.playbackState = playbackState
self.onSeek = onSeek
}
// MARK: - Widget
public func container<Data>(data: WidgetData, type: Data.Type) -> ViewStorage
where Data: ViewRenderData {
let widgetPtr = gtk_scale_new_with_range(
.GTK_ORIENTATION_HORIZONTAL,
range.lowerBound,
range.upperBound,
1.0
)
let scale = widgetPtr.map(OpaquePointer.init)
gtk_scale_set_draw_value(scale?.cast(), 0)
gtk_widget_set_hexpand(scale?.cast(), 1)
gtk_range_set_show_fill_level(scale?.cast(), 1)
gtk_range_set_restrict_to_fill_level(scale?.cast(), 0)
// Register scale for direct C-level updates during playback
if let scale {
playbackState?.seekScale = scale
}
let storage = ViewStorage(scale)
let context = SeekBarContext()
context.onSeek = onSeek
let binding = _value
context.setValue = { binding.wrappedValue = $0 }
storage.fields["context"] = context
let callback: @convention(c) (
OpaquePointer?,
GtkScrollType,
Double,
UnsafeMutableRawPointer?
) -> Int32 = { _, _, val, userData in
let ctx = Unmanaged<SeekBarContext>.fromOpaque(userData!).takeUnretainedValue()
ctx.setValue?(val)
ctx.onSeek?(val)
return 0
}
g_signal_connect_data(
scale.map(UnsafeMutableRawPointer.init),
"change-value",
unsafeBitCast(callback, to: GCallback.self),
Unmanaged.passUnretained(context).toOpaque(),
nil,
GConnectFlags(rawValue: 1)
)
for function in appearFunctions {
function(storage, data)
}
return storage
}
public func update<Data>(
_ storage: ViewStorage,
data: WidgetData,
updateProperties: Bool,
type: Data.Type
) where Data: ViewRenderData {
storage.modify { widget in
let currentValue = gtk_range_get_value(widget?.cast())
if abs(currentValue - value) > 0.5 {
gtk_range_set_value(widget?.cast(), value)
}
}
for function in updateFunctions {
function(storage, data, updateProperties)
}
if updateProperties {
storage.previousState = self
}
}
}
// MARK: - Context
private class SeekBarContext {
var setValue: ((Double) -> Void)?
var onSeek: ((Double) -> Void)?
}