Video: playback, pause and seeking
A complete C# UI, Theora OGV, aspect ratio, looping and chroma key.
Supported input
This build's built-in decoder reads Theora in an Ogg container, using .ogv. An extension does not convert a codec; renaming MP4 to OGV will not help. Other formats need a separate VideoStream implementation or extension. Built-in MP4, H.264 or WebM support is not supplied by this example.
Download the teaching OGV for Reckfell 1.0.0. It is original footage from the frame-recording example: 512 × 288 at 30 FPS for 4 seconds. No external footage or encoder installation is required to try it.
Run the interface
Create a Control level root. Save the clip as Clips/recording-slate.ogv, add VideoDemo.cs, attach it to the root, build and run with F6. Press Play. A blue square moves across the screen; Pause retains the position, Resume continues and Stop stops playback. Pressing Play again starts from the beginning.
VideoDemo.cs
using System;
using Reckfell;
public partial class VideoDemo : Control
{
[Export(PropertyHint.File,"*.ogv")] public string VideoPath { get; set; } = "asset://Clips/recording-slate.ogv";
public VideoStreamPlayer Video { get; private set; } = null!;
public Label Status { get; private set; } = null!;
public Button PauseButton { get; private set; } = null!;
public int FinishedCount { get; private set; }
private bool _russian;
private string Text(string ru,string en) => _russian ? ru : en;
public override void _Ready()
{
_russian=TranslationServer.GetLocale().StartsWith("ru",StringComparison.OrdinalIgnoreCase);
SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
var layout=new VBoxContainer { Name="Layout" }; AddChild(layout); layout.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
var aspect=new AspectRatioContainer
{
Name="Aspect",Ratio=16f/9,StretchMode=AspectRatioContainer.StretchModeEnum.Fit,
SizeFlagsVertical=SizeFlags.ExpandFill
};
Video=new VideoStreamPlayer { Name="Video",Expand=true,Autoplay=false,Loop=false };
layout.AddChild(aspect); aspect.AddChild(Video);
var buttons=new HBoxContainer { Name="Buttons" }; layout.AddChild(buttons);
var play=new Button { Name="PlayButton",Text=Text("Запустить","Play") };
PauseButton=new Button { Name="PauseButton",Text=Text("Пауза","Pause") };
var stop=new Button { Name="StopButton",Text=Text("Остановить","Stop") };
buttons.AddChild(play); buttons.AddChild(PauseButton); buttons.AddChild(stop);
Status=new Label { Name="Status",Text=Text("Готово к запуску","Ready") }; layout.AddChild(Status);
play.Pressed+=() => ShowResult(LoadAndPlay());
PauseButton.Pressed+=TogglePause;
stop.Pressed+=StopVideo;
Video.Finished+=() =>
{
FinishedCount++;
Status.Text=Text("Воспроизведение завершено","Finished");
PauseButton.Text=Text("Пауза","Pause");
};
}
public Error LoadAndPlay()
{
if (!IsInsideTree()) return Error.Unconfigured;
if (!FileAccess.FileExists(VideoPath)) return Error.FileNotFound;
if (!VideoPath.EndsWith(".ogv",StringComparison.OrdinalIgnoreCase)) return Error.Unavailable;
var stream=ResourceLoader.Load<VideoStream>(VideoPath);
if (stream is null) return Error.FileUnrecognized;
Video.Paused=false; Video.Stream=stream; Video.Play();
PauseButton.Text=Text("Пауза","Pause");
Status.Text=Text("Воспроизведение","Playing");
return Error.Ok;
}
private void ShowResult(Error error)
{
if (error != Error.Ok) Status.Text=$"{Text("Ошибка","Error")}: {error}";
}
public void TogglePause()
{
if (!Video.IsPlaying()) return;
Video.Paused=!Video.Paused;
PauseButton.Text=Video.Paused ? Text("Продолжить","Resume") : Text("Пауза","Pause");
Status.Text=Video.Paused ? Text("Пауза","Paused") : Text("Воспроизведение","Playing");
}
public void StopVideo()
{
Video.Stop(); Video.Paused=false;
PauseButton.Text=Text("Пауза","Pause"); Status.Text=Text("Остановлено","Stopped");
}
public Error SeekTo(double seconds)
{
double length=Video.GetStreamLength();
if (!double.IsFinite(seconds) || seconds<0 || length<=0 || seconds>=length) return Error.InvalidParameter;
Video.StreamPosition=seconds;
return Error.Ok;
}
}The UI chooses RU or EN when created using TranslationServer.GetLocale(). LoadAndPlay() checks the path before replacing the current stream; a missing file does not stop an already playing clip. On first use without a file, status reports the error instead of dereferencing null.
Size and aspect
Expand = true lets the container size VideoStreamPlayer instead of using the frame size as a minimum. AspectRatioContainer with Ratio = 16/9 and Fit preserves proportions. Set the corresponding Ratio for another source aspect; unused space is expected when aspects differ. Stretching the player to an arbitrary rectangle does not itself preserve aspect.
Time and completion
| Operation | Contract |
|---|---|
Paused = true | Pauses time; IsPlaying() can remain true |
Paused = false | Resumes from the retained position |
Stop() | Stops; it is not natural completion and does not emit Finished |
StreamPosition = seconds | The current Theora decoder supports seeking; use a valid time inside the length |
Loop = true | Repeats without emitting Finished on every loop |
SeekTo() takes seconds and rejects negative values, NaN and positions at or beyond the end. Decoding may work forward from a keyframe, so seeking a long file need not be instantaneous. Check the visible frame after decoder processing; assigning time is not completion. Do not assume every VideoStream plugin has Theora's behavior.
Looping is not a guarantee of gapless sound or an invisible boundary. Keep explicit gameplay and cancellation state instead of waiting forever for a looping clip's Finished signal.
Audio and workload
Check AudioTrack, Bus, VolumeDb and the output bus. The teaching clip deliberately has a quiet audio track. Theora decodes on the CPU; measure resolution, FPS and concurrent streams on the target device. BufferingMsec does not enable hardware decoding or provide a missing codec. Assigning a network URL to VideoPath does not create HTTP streaming; this example reads local resources.
Video on a 3D surface
Obtain the live texture with GetVideoTexture() and assign it to StandardMaterial3D.AlbedoTexture. Use Unshaded if lighting should not change the video. Keep VideoStreamPlayer in the tree and playing. Do not copy each frame through GetImage() for normal display; that adds GPU readback. Use a SubViewport texture when you need an entire interface composition on the surface.
Chroma key
Save this as ChromaKey.reckfellshader, create a ShaderMaterial and assign it to the player's Material. The default key is green; threshold and positive softness control removal and edge softness. The shader retains Modulate through vertex color.
shader_type canvas_item;
uniform vec4 key_color : source_color = vec4(0.0, 1.0, 0.0, 1.0);
uniform float threshold : hint_range(0.0, 1.0) = 0.15;
uniform float softness : hint_range(0.001, 0.3) = 0.03;
varying vec4 tint;
void vertex() {
tint = COLOR;
}
void fragment() {
vec4 pixel = texture(TEXTURE, UV);
float difference = distance(pixel.rgb, key_color.rgb);
float alpha = smoothstep(threshold - softness, threshold + softness, difference);
COLOR = pixel * tint;
COLOR.a *= alpha;
}This does not add an alpha channel to the Theora file; transparency is computed when displayed. Compression mixes edge colors, so a clean source background and moderate threshold matter more than trying to fix everything with one parameter.
Diagnostics
| Symptom | Check |
|---|---|
FileNotFound | Name, case, Clips directory and inclusion in the export |
FileUnrecognized | A real Theora codec, valid Ogg and an available decoder |
| Play shows nothing | Error output, Control size and a valid Stream |
| Stretched image | Expand, container Ratio and StretchMode |
| Time does not move | Paused, speed, tree pause and frame processing |
| Finished never arrives | Loop or explicit stopping instead of completion |
| MP4 works in a browser only | A browser decoder does not add VideoStreamPlayer support |