Fix stutter by removing StateManager calls from playback path

This commit is contained in:
Brendan Szymanski 2026-06-25 19:24:34 -04:00
parent 29acef1e0a
commit 447d370c7f
5 changed files with 149 additions and 62 deletions

View file

@ -3,7 +3,9 @@
#include <mpv/render_gl.h>
#include <epoxy/gl.h>
#include <dlfcn.h>
#include <locale.h>
#include <math.h>
#include <string.h>
struct _MpvWidget {
GtkWidget parent_instance;
@ -14,6 +16,9 @@ struct _MpvWidget {
double position;
double duration;
gboolean paused;
char *pending_url;
gboolean initialized;
gboolean should_play;
};
enum {
@ -32,7 +37,13 @@ static gboolean
mpv_widget_render_frame(gpointer user_data)
{
MpvWidget *self = MPV_WIDGET(user_data);
gtk_gl_area_queue_render(GTK_GL_AREA(self->gl_area));
if (!self->mpv_gl)
return G_SOURCE_REMOVE;
int64_t flags = mpv_render_context_update(self->mpv_gl);
if (flags & MPV_RENDER_UPDATE_FRAME)
gtk_gl_area_queue_render(GTK_GL_AREA(self->gl_area));
return G_SOURCE_REMOVE;
}
@ -40,18 +51,16 @@ static void
mpv_render_update(void *cb_ctx)
{
MpvWidget *self = MPV_WIDGET(cb_ctx);
if (self->mpv_gl) {
int64_t flags = mpv_render_context_update(self->mpv_gl);
if (flags & MPV_RENDER_UPDATE_FRAME) {
g_idle_add(mpv_widget_render_frame, self);
}
}
if (self->mpv_gl)
g_idle_add(mpv_widget_render_frame, self);
}
static void *
mpv_gl_get_proc_address(void *fn_ctx, const char *name)
{
(void)fn_ctx;
if (!name)
return NULL;
return dlsym(RTLD_DEFAULT, name);
}
@ -64,12 +73,14 @@ mpv_widget_on_realize(GtkWidget *widget, gpointer user_data)
gtk_gl_area_make_current(GTK_GL_AREA(widget));
setlocale(LC_NUMERIC, "C");
self->mpv = mpv_create();
if (!self->mpv)
return;
mpv_set_option_string(self->mpv, "vo", "libmpv");
mpv_set_option_string(self->mpv, "hwdec", "auto");
mpv_set_option_string(self->mpv, "hwdec", "no");
mpv_set_option_string(self->mpv, "keep-open", "yes");
if (mpv_initialize(self->mpv) < 0) {
@ -78,9 +89,10 @@ mpv_widget_on_realize(GtkWidget *widget, gpointer user_data)
return;
}
GdkGLContext *gl_context = gtk_gl_area_get_context(GTK_GL_AREA(widget));
mpv_opengl_init_params gl_init = {
.get_proc_address = mpv_gl_get_proc_address,
.get_proc_address_ctx = NULL,
.get_proc_address_ctx = gl_context,
};
mpv_render_param params[] = {
@ -98,7 +110,16 @@ mpv_widget_on_realize(GtkWidget *widget, gpointer user_data)
mpv_render_context_set_update_callback(
self->mpv_gl, mpv_render_update, self);
self->tick_source = g_timeout_add(250, (GSourceFunc)mpv_widget_tick, self);
self->tick_source = g_timeout_add(500, (GSourceFunc)mpv_widget_tick, self);
self->initialized = TRUE;
if (self->pending_url) {
mpv_command(self->mpv, (const char *[]){"loadfile", self->pending_url, NULL});
mpv_set_property_string(self->mpv, "pause",
self->should_play ? "no" : "yes");
g_free(self->pending_url);
self->pending_url = NULL;
}
}
static void
@ -149,10 +170,12 @@ mpv_widget_on_render(GtkGLArea *area, GdkGLContext *context, gpointer user_data)
};
int flip_y = 1;
int block = 0;
mpv_render_param params[] = {
{MPV_RENDER_PARAM_OPENGL_FBO, &fbo},
{MPV_RENDER_PARAM_FLIP_Y, &flip_y},
{MPV_RENDER_PARAM_BLOCK_FOR_TARGET_TIME, &block},
{0}
};
@ -173,16 +196,16 @@ mpv_widget_tick(gpointer user_data)
mpv_event *event = mpv_wait_event(self->mpv, 0);
if (event->event_id == MPV_EVENT_NONE)
break;
if (event->event_id == MPV_EVENT_END_FILE) {
if (event->event_id == MPV_EVENT_END_FILE)
g_signal_emit(self, signals[SIGNAL_PLAYBACK_STATE_CHANGED], 0, TRUE);
}
}
double pos = 0;
if (mpv_get_property(self->mpv, "time-pos",
MPV_FORMAT_DOUBLE, &pos) == MPV_ERROR_SUCCESS)
{
if (fabs(pos - self->position) > 0.1) {
double diff = fabs(pos - self->position);
if (diff > 1.0) {
self->position = pos;
g_signal_emit(self, signals[SIGNAL_POSITION_CHANGED], 0, pos);
}
@ -276,6 +299,9 @@ mpv_widget_dispose(GObject *object)
self->tick_source = 0;
}
g_free(self->pending_url);
self->pending_url = NULL;
if (self->gl_area) {
gtk_widget_unparent(self->gl_area);
self->gl_area = NULL;
@ -305,6 +331,9 @@ mpv_widget_init(MpvWidget *self)
self->duration = 0.0;
self->paused = TRUE;
self->tick_source = 0;
self->pending_url = NULL;
self->initialized = FALSE;
self->should_play = FALSE;
}
static void
@ -367,8 +396,11 @@ mpv_widget_load_url(MpvWidget *self, const char *url)
g_return_if_fail(MPV_IS_WIDGET(self));
g_return_if_fail(url != NULL);
if (!self->mpv)
if (!self->mpv) {
g_free(self->pending_url);
self->pending_url = g_strdup(url);
return;
}
const char *cmd[] = {"loadfile", url, NULL};
mpv_command(self->mpv, cmd);
@ -380,8 +412,11 @@ mpv_widget_play(MpvWidget *self)
{
g_return_if_fail(MPV_IS_WIDGET(self));
if (self->mpv)
mpv_set_property_string(self->mpv, "pause", "no");
if (!self->mpv) {
self->should_play = TRUE;
return;
}
mpv_set_property_string(self->mpv, "pause", "no");
}
void
@ -389,8 +424,11 @@ mpv_widget_pause(MpvWidget *self)
{
g_return_if_fail(MPV_IS_WIDGET(self));
if (self->mpv)
mpv_set_property_string(self->mpv, "pause", "yes");
if (!self->mpv) {
self->should_play = FALSE;
return;
}
mpv_set_property_string(self->mpv, "pause", "yes");
}
void

View file

@ -0,0 +1,30 @@
//
// PlayerPlaybackState.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 Foundation
/// Shared mutable state for direct C widget manipulation during playback.
/// Accessed from signal handlers to update GTK widgets without triggering
/// Adwaita's `StateManager.updateViews()`.
public final class PlayerPlaybackState {
public var seekScale: OpaquePointer?
public init() {}
}

View file

@ -20,6 +20,7 @@
//
import Adwaita
import LuminateCore
import LuminateUI
public struct PlayerControls: View {
@ -27,6 +28,7 @@ public struct PlayerControls: View {
@Binding var isPlaying: Bool
@Binding var position: Double
@Binding var duration: Double
var playbackState: PlayerPlaybackState?
public var onClose: () -> Void
public var onSeekBack: () -> Void
public var onSeekForward: () -> Void
@ -34,10 +36,11 @@ public struct PlayerControls: View {
public var onFullscreen: () -> Void
public var onSubtitleAudio: () -> Void
public init(
init(
isPlaying: Binding<Bool>,
position: Binding<Double>,
duration: Binding<Double>,
playbackState: PlayerPlaybackState? = nil,
onClose: @escaping () -> Void,
onSeekBack: @escaping () -> Void,
onSeekForward: @escaping () -> Void,
@ -48,6 +51,7 @@ public struct PlayerControls: View {
self._isPlaying = isPlaying
self._position = position
self._duration = duration
self.playbackState = playbackState
self.onClose = onClose
self.onSeekBack = onSeekBack
self.onSeekForward = onSeekForward
@ -83,6 +87,7 @@ public struct PlayerControls: View {
SeekBar(
value: $position,
range: 0...(duration > 0 ? duration : 1),
playbackState: playbackState,
onSeek: { newPos in
onSeekAbsolute(newPos)
}

View file

@ -47,6 +47,8 @@ public struct PlayerView: View {
@State private var mpvWidget: OpaquePointer?
@State private var tasks = PlayerTasks()
private let playbackState = PlayerPlaybackState()
public init(playerState: PlayerState, onClose: @escaping () -> Void) {
self.playerState = playerState
self.onClose = onClose
@ -58,7 +60,8 @@ public struct PlayerView: View {
isPlaying: $isPlaying,
position: $position,
duration: $duration,
onWidgetCreated: { ptr in /*mpvWidget = ptr*/ }
playbackState: playbackState,
onWidgetCreated: { ptr in _mpvWidget.rawValue = ptr }
)
.vexpand(true)
.hexpand(true)
@ -79,6 +82,7 @@ public struct PlayerView: View {
isPlaying: $isPlaying,
position: $position,
duration: $duration,
playbackState: playbackState,
onClose: {
stopPlayback()
onClose()
@ -204,14 +208,22 @@ struct VideoPlayerWidget: Widget {
@Binding var isPlaying: Bool
@Binding var position: Double
@Binding var duration: Double
var playbackState: PlayerPlaybackState?
var onWidgetCreated: ((OpaquePointer) -> Void)?
init(url: String?, isPlaying: Binding<Bool>, position: Binding<Double>, duration: Binding<Double>, onWidgetCreated: ((OpaquePointer) -> Void)? = nil) {
init(
url: String?,
isPlaying: Binding<Bool>,
position: Binding<Double>,
duration: Binding<Double>,
playbackState: PlayerPlaybackState? = nil,
onWidgetCreated: ((OpaquePointer) -> Void)? = nil
) {
self.url = url
self._isPlaying = isPlaying
self._position = position
self._duration = duration
self.playbackState = playbackState
self.onWidgetCreated = onWidgetCreated
}
@ -220,61 +232,49 @@ struct VideoPlayerWidget: Widget {
let storage = ViewStorage(mpv_widget_new()?.opaque())
if let widgetPtr = storage.opaquePointer.map(UnsafeMutableRawPointer.init) {
let context = SeekBarBindingContext(
position: _position,
let ctx = SignalContext(
duration: _duration,
isPlaying: _isPlaying
isPlaying: _isPlaying,
playbackState: playbackState
)
storage.fields["context"] = context
let ctxPtr = Unmanaged.passUnretained(context).toOpaque()
storage.fields["ctx"] = ctx
let ctxPtr = Unmanaged.passUnretained(ctx).toOpaque()
let posHandler: @convention(c) (
OpaquePointer?,
Double,
UnsafeMutableRawPointer?
OpaquePointer?, Double, UnsafeMutableRawPointer?
) -> Void = { _, pos, ptr in
let binding = Unmanaged<SeekBarBindingContext>.fromOpaque(ptr!).takeUnretainedValue()
binding.position.wrappedValue = pos
let ctx = Unmanaged<SignalContext>.fromOpaque(ptr!).takeUnretainedValue()
if let scale = ctx.playbackState?.seekScale {
let range = unsafeBitCast(scale, to: UnsafeMutablePointer<GtkRange>?.self)
gtk_range_set_value(range, pos)
}
}
g_signal_connect_data(
widgetPtr,
"position-changed",
widgetPtr, "position-changed",
unsafeBitCast(posHandler, to: GCallback.self),
ctxPtr,
nil,
GConnectFlags(rawValue: 1))
ctxPtr, nil, GConnectFlags(rawValue: 1))
let durHandler: @convention(c) (
OpaquePointer?,
Double,
UnsafeMutableRawPointer?
OpaquePointer?, Double, UnsafeMutableRawPointer?
) -> Void = { _, dur, ptr in
let binding = Unmanaged<SeekBarBindingContext>.fromOpaque(ptr!).takeUnretainedValue()
binding.duration.wrappedValue = dur
let ctx = Unmanaged<SignalContext>.fromOpaque(ptr!).takeUnretainedValue()
ctx.duration.wrappedValue = dur
}
g_signal_connect_data(
widgetPtr,
"duration-changed",
widgetPtr, "duration-changed",
unsafeBitCast(durHandler, to: GCallback.self),
ctxPtr,
nil,
GConnectFlags(rawValue: 1))
ctxPtr, nil, GConnectFlags(rawValue: 1))
let stateHandler: @convention(c) (
OpaquePointer?,
Int32,
UnsafeMutableRawPointer?
OpaquePointer?, Int32, UnsafeMutableRawPointer?
) -> Void = { _, paused, ptr in
let binding = Unmanaged<SeekBarBindingContext>.fromOpaque(ptr!).takeUnretainedValue()
binding.isPlaying.wrappedValue = paused == 0
let ctx = Unmanaged<SignalContext>.fromOpaque(ptr!).takeUnretainedValue()
ctx.isPlaying.wrappedValue = paused == 0
}
g_signal_connect_data(
widgetPtr,
"playback-state-changed",
widgetPtr, "playback-state-changed",
unsafeBitCast(stateHandler, to: GCallback.self),
ctxPtr,
nil,
GConnectFlags(rawValue: 1))
ctxPtr, nil, GConnectFlags(rawValue: 1))
}
if let ptr = storage.opaquePointer, let onWidgetCreated {
@ -320,16 +320,21 @@ struct VideoPlayerWidget: Widget {
}
// MARK: - Signal binding context
// MARK: - Signal context
class SeekBarBindingContext {
var position: Binding<Double>
class SignalContext {
var duration: Binding<Double>
var isPlaying: Binding<Bool>
weak var playbackState: PlayerPlaybackState?
init(position: Binding<Double>, duration: Binding<Double>, isPlaying: Binding<Bool>) {
self.position = position
init(
duration: Binding<Double>,
isPlaying: Binding<Bool>,
playbackState: PlayerPlaybackState?
) {
self.duration = duration
self.isPlaying = isPlaying
self.playbackState = playbackState
}
}

View file

@ -21,6 +21,7 @@
import Adwaita
import CAdw
import LuminateCore
/// A seekable progress slider wrapping `GtkScale`.
///
@ -44,15 +45,18 @@ public struct SeekBar: Widget {
@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
}
@ -72,6 +76,11 @@ public struct SeekBar: Widget {
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()