Skip to main content

Media Player

Drift provides native media playback through two APIs: the VideoPlayerController for embedded video with platform controls, and the AudioPlayerController for headless audio playback with a custom UI. Audio and video controllers share the same method set but are separate types. The video controller creates its native surface eagerly on construction, while the audio controller defers native player creation until the first method call.

Both APIs deliver callbacks on the UI thread, so you can update widget state directly without wrapping calls in drift.Dispatch.

Video Player

The VideoPlayer widget embeds a native video player (ExoPlayer on Android, AVPlayer on iOS) with built-in transport controls including play/pause, seek bar, and time display.

Create a VideoPlayerController with UseDisposable, set callbacks, and pass it to the widget:

import (
"time"

"github.com/go-drift/drift/pkg/core"
"github.com/go-drift/drift/pkg/platform"
"github.com/go-drift/drift/pkg/theme"
"github.com/go-drift/drift/pkg/widgets"
)

type playerState struct {
core.StateBase
controller *platform.VideoPlayerController
status *core.Signal[string]
}

func (s *playerState) InitState() {
s.status = core.NewSignal("Idle")
core.UseListenable(&s.StateBase, s.status)
s.controller = platform.NewVideoPlayerController()
core.UseDisposable(&s.StateBase, s.controller)

s.controller.OnPlaybackStateChanged = func(state platform.PlaybackState) {
s.status.Set(state.String())
}
s.controller.OnPositionChanged = func(position, duration, buffered time.Duration) {
s.status.Set(position.String() + " / " + duration.String())
}
s.controller.OnError = func(code, message string) {
s.status.Set("Error (" + code + "): " + message)
}

s.controller.Load("https://example.com/video.mp4")
}

func (s *playerState) Build(ctx core.BuildContext) core.Widget {
return widgets.Column{
Children: []core.Widget{
widgets.VideoPlayer{
Controller: s.controller,
Height: 225,
},
widgets.Row{
Children: []core.Widget{
theme.ButtonOf(ctx, "Pause", func() {
s.controller.Pause()
}),
theme.ButtonOf(ctx, "Seek +10s", func() {
pos := s.controller.Position()
s.controller.SeekTo(pos + 10*time.Second)
}),
},
},
},
}
}

Width and Height set explicit dimensions in logical pixels. To fill available width, wrap the widget in layout widgets such as Expanded inside a Row:

widgets.Row{
Children: []core.Widget{
widgets.Expanded{
Child: widgets.VideoPlayer{
Controller: s.controller,
Height: 225,
},
},
},
}

Set all callbacks before calling Load, Play, or any other playback method. Callbacks are checked when events arrive from the native player, so any assigned after playback starts may miss early events.

UseDisposable registers a dispose callback automatically, so the controller is released when the widget is removed from the tree. Disposing a controller that is still buffering silently cancels playback; no further callbacks are delivered. For non-widget contexts (tests, standalone services), use platform.NewVideoPlayerController() directly and call Dispose() manually.

VideoPlayer Widget Fields

FieldTypeDescription
Controller*platform.VideoPlayerControllerThe controller that provides the native surface and playback control
Widthfloat64Player width in logical pixels
Heightfloat64Player height in logical pixels
HideControlsboolHide native transport controls. Use when building custom Drift widget controls on top of the video surface.

VideoPlayerController Methods

All methods are safe for concurrent use. Set callback fields before calling Load.

MethodDescription
Load(url string) errorLoad a media URL. The native player begins buffering the media source.
Play() errorStart or resume playback
Pause() errorPause playback
Stop() errorStop playback and reset to idle. Media stays loaded; calling Play restarts from the beginning. Use Dispose to release resources.
SeekTo(position time.Duration) errorSeek to a position
SetVolume(volume float64) errorSet volume (0.0 to 1.0). Values outside this range are clamped by the native player.
SetLooping(looping bool) errorEnable or disable looping
SetPlaybackSpeed(rate float64) errorSet playback speed (1.0 = normal). Must be positive; behavior for zero or negative values is platform-dependent.
SetShowControls(show bool) errorShow or hide native transport controls at runtime.
State() PlaybackStateCurrent playback state
Position() time.DurationCurrent playback position
Duration() time.DurationTotal media duration
Buffered() time.DurationBuffered position
ViewID() int64Platform view ID, or 0 if the view was not created
Dispose()Release native resources. The controller must not be reused after disposal.

VideoPlayerController Callbacks

FieldTypeDescription
OnPlaybackStateChangedfunc(PlaybackState)Called when playback state changes (UI thread)
OnPositionChangedfunc(position, duration, buffered time.Duration)Called approximately every 250ms while media is loaded (UI thread)
OnErrorfunc(code, message string)Called when a playback error occurs (UI thread)

Audio Player

AudioPlayerController provides audio playback without a visual component. It uses a standalone platform channel, so there is no embedded native view. Build your own UI around the controller.

Multiple controllers may exist concurrently, each managing its own native player instance. Call Dispose to release resources when a controller is no longer needed.

import (
"time"

"github.com/go-drift/drift/pkg/core"
"github.com/go-drift/drift/pkg/platform"
"github.com/go-drift/drift/pkg/theme"
"github.com/go-drift/drift/pkg/widgets"
)

type audioState struct {
core.StateBase
controller *platform.AudioPlayerController
status *core.Signal[string]
}

func (s *audioState) InitState() {
s.status = core.NewSignal("Idle")
core.UseListenable(&s.StateBase, s.status)
s.controller = platform.NewAudioPlayerController()
core.UseDisposable(&s.StateBase, s.controller)

// Callbacks are delivered on the UI thread.
s.controller.OnPlaybackStateChanged = func(state platform.PlaybackState) {
s.status.Set(state.String())
}
s.controller.OnPositionChanged = func(position, duration, buffered time.Duration) {
s.status.Set(position.String() + " / " + duration.String())
}
s.controller.OnError = func(code, message string) {
s.status.Set("Error (" + code + "): " + message)
}

s.controller.Load("https://example.com/song.mp3")
}

Set all callbacks before calling Load, Play, or any other playback method. Callbacks are checked when events arrive from the native player, so any assigned after playback starts may miss early events.

UseDisposable registers a dispose callback automatically, so the controller is released when the widget is removed from the tree. Disposing a controller that is still buffering silently cancels playback; no further callbacks are delivered. For non-widget contexts (tests, standalone services), use platform.NewAudioPlayerController() directly and call Dispose() manually.

AudioPlayerController Methods

All methods are safe for concurrent use. Set callback fields before calling Load.

MethodDescription
Load(url string) errorLoad a media URL. The native player begins buffering the media source.
Play() errorStart or resume playback
Pause() errorPause playback
Stop() errorStop playback and reset to idle. Media stays loaded; calling Play restarts from the beginning. Use Dispose to release resources.
SeekTo(position time.Duration) errorSeek to a position
SetVolume(volume float64) errorSet volume (0.0 to 1.0). Values outside this range are clamped by the native player.
SetLooping(looping bool) errorEnable or disable looping
SetPlaybackSpeed(rate float64) errorSet playback speed (1.0 = normal). Must be positive; behavior for zero or negative values is platform-dependent.
State() PlaybackStateCurrent playback state
Position() time.DurationCurrent playback position
Duration() time.DurationTotal media duration
Buffered() time.DurationBuffered position
Dispose()Release native resources. Idempotent; safe to call more than once.

AudioPlayerController Callbacks

FieldTypeDescription
OnPlaybackStateChangedfunc(PlaybackState)Called when playback state changes (UI thread)
OnPositionChangedfunc(position, duration, buffered time.Duration)Called approximately every 250ms while media is loaded (UI thread)
OnErrorfunc(code, message string)Called when a playback error occurs (UI thread)

Example: Transport Controls with Seek

func (s *audioState) Build(ctx core.BuildContext) core.Widget {
return widgets.Column{
Children: []core.Widget{
widgets.Text{Content: s.status.Value()},
widgets.Row{
Children: []core.Widget{
theme.ButtonOf(ctx, "Play", func() {
s.controller.Play()
}),
theme.ButtonOf(ctx, "Pause", func() {
s.controller.Pause()
}),
theme.ButtonOf(ctx, "Stop", func() {
s.controller.Stop()
}),
},
},
widgets.Row{
Children: []core.Widget{
theme.ButtonOf(ctx, "Seek +10s", func() {
pos := s.controller.Position()
s.controller.SeekTo(pos + 10*time.Second)
}),
theme.ButtonOf(ctx, "Loop", func() {
s.controller.SetLooping(true)
}),
theme.ButtonOf(ctx, "Mute", func() {
s.controller.SetVolume(0)
}),
},
},
},
}
}

Playback States

Both video and audio players share the same PlaybackState enum (defined in platform). Use the String() method for human-readable labels.

StateValueDescription
PlaybackStateIdle0Player created, no media loaded
PlaybackStateBuffering1Buffering media data before playback can continue
PlaybackStatePlaying2Actively playing media
PlaybackStateCompleted3Playback reached the end of the media
PlaybackStatePaused4Paused, can be resumed

Errors are delivered through the OnError callback rather than as a playback state.

Error Codes

Control methods like Load and Play return an error that indicates a communication failure with the native player (for example, calling a method after disposal). The OnError callback fires for playback-time errors reported by the native player, such as network failures or unsupported codecs.

Both controllers use canonical error codes that are consistent across Android and iOS.

CodeConstantDescription
"source_error"platform.ErrCodeSourceErrorMedia source could not be loaded (network failure, invalid URL, unsupported format)
"decoder_error"platform.ErrCodeDecoderErrorMedia could not be decoded or rendered (codec failure, DRM error)
"playback_failed"platform.ErrCodePlaybackFailedGeneral playback failure that does not fit a more specific category

Native implementations map platform-specific errors to these codes, so error handling behaves the same on Android and iOS.

controller.OnError = func(code, message string) {
switch code {
case platform.ErrCodeSourceError:
// Network or URL issue, prompt user to check connection
case platform.ErrCodeDecoderError:
// Format not supported on this device
default:
// General failure
}
log.Printf("playback error [%s]: %s", code, message)
}

Custom Controls

To build your own playback controls in Drift widgets, set HideControls: true on the VideoPlayer widget. This hides the native transport UI (play/pause button, seek bar, time display) and leaves a bare video surface that you can overlay with Drift widgets using Stack and Positioned:

widgets.Stack{
Children: []core.Widget{
widgets.VideoPlayer{
Controller: s.controller,
Height: 300,
HideControls: true,
},
widgets.Positioned(widgets.Row{
Children: []core.Widget{
theme.ButtonOf(ctx, "Play", func() {
s.controller.Play()
}),
theme.ButtonOf(ctx, "Pause", func() {
s.controller.Pause()
}),
},
}).Bottom(8).Left(8),
},
}

You can also toggle controls at runtime via the controller:

s.controller.SetShowControls(false) // hide
s.controller.SetShowControls(true) // show again

Cleartext HTTP URLs

Android (API 28+) and iOS block cleartext HTTP traffic by default. If you load an http:// URL, the native player will report a source_error. HTTPS URLs work without any extra configuration.

To allow HTTP URLs, set allow_http: true in your drift.yaml:

app:
allow_http: true

This adds android:usesCleartextTraffic="true" to the Android manifest and an NSAppTransportSecurity exception to the iOS Info.plist. Use HTTPS whenever possible and only enable this setting when your media source does not support it.

tip

UseDisposable is documented in the State Management guide.

Next Steps