diff --git a/Sources/CModules/mpv_widget.c b/Sources/CModules/mpv_widget.c index 290d98f..4b83cf6 100644 --- a/Sources/CModules/mpv_widget.c +++ b/Sources/CModules/mpv_widget.c @@ -3,7 +3,9 @@ #include #include #include +#include #include +#include 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 diff --git a/Sources/LuminateCore/PlayerPlaybackState.swift b/Sources/LuminateCore/PlayerPlaybackState.swift new file mode 100644 index 0000000..9b0875c --- /dev/null +++ b/Sources/LuminateCore/PlayerPlaybackState.swift @@ -0,0 +1,30 @@ +// +// PlayerPlaybackState.swift +// +// Copyright 2026 Brendan Szymanski +// +// 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 . +// +// 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() {} +} diff --git a/Sources/LuminatePlayer/PlayerControls.swift b/Sources/LuminatePlayer/PlayerControls.swift index 5d06925..d9030fa 100644 --- a/Sources/LuminatePlayer/PlayerControls.swift +++ b/Sources/LuminatePlayer/PlayerControls.swift @@ -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, position: Binding, duration: Binding, + 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) } diff --git a/Sources/LuminatePlayer/PlayerView.swift b/Sources/LuminatePlayer/PlayerView.swift index e6c4a40..a02708c 100644 --- a/Sources/LuminatePlayer/PlayerView.swift +++ b/Sources/LuminatePlayer/PlayerView.swift @@ -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, position: Binding, duration: Binding, onWidgetCreated: ((OpaquePointer) -> Void)? = nil) { + init( + url: String?, + isPlaying: Binding, + position: Binding, + duration: Binding, + 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.fromOpaque(ptr!).takeUnretainedValue() - binding.position.wrappedValue = pos + let ctx = Unmanaged.fromOpaque(ptr!).takeUnretainedValue() + if let scale = ctx.playbackState?.seekScale { + let range = unsafeBitCast(scale, to: UnsafeMutablePointer?.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.fromOpaque(ptr!).takeUnretainedValue() - binding.duration.wrappedValue = dur + let ctx = Unmanaged.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.fromOpaque(ptr!).takeUnretainedValue() - binding.isPlaying.wrappedValue = paused == 0 + let ctx = Unmanaged.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 +class SignalContext { var duration: Binding var isPlaying: Binding + weak var playbackState: PlayerPlaybackState? - init(position: Binding, duration: Binding, isPlaying: Binding) { - self.position = position + init( + duration: Binding, + isPlaying: Binding, + playbackState: PlayerPlaybackState? + ) { self.duration = duration self.isPlaying = isPlaying + self.playbackState = playbackState } } + diff --git a/Sources/LuminateUI/Components/SeekBar.swift b/Sources/LuminateUI/Components/SeekBar.swift index 10f4717..0791940 100644 --- a/Sources/LuminateUI/Components/SeekBar.swift +++ b/Sources/LuminateUI/Components/SeekBar.swift @@ -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 + var playbackState: PlayerPlaybackState? var onSeek: ((Double) -> Void)? public init( value: Binding, range: ClosedRange, + 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()