Video and audio: what does the package do, and what does the project draw?
dartway_media_flutter is the player every DartWay app builds on, reached as dw.plugins.media:
one controller over video_player and just_audio, sessions that outlive the page that opened
them, a queue, resume, fullscreen and a mini-player.
Mechanism only — no look. The package has no button, no timeline, no colour and no text (see
the UI kit), and no chewie, which ships Material and Cupertino controls — a design
decision the package does not make. The controls an app shows are its own widgets: the default
set lives in example/dartway_example_flutter/lib/ui_kit/3_special/media/, and a project copies
it into its own ui_kit/ and restyles it (the dartway-media toolkit skill walks through it). A
project that plays nothing does not add the package, and does not download video_player.
Nothing is fixed policy. Every behaviour below is a setting with a default on DwMediaConfig,
and every one can be overridden for a single session — see every setting.
Wiring
dw = DwFlutterCore(
// ...
plugins: [
DwMedia(config: const DwMediaConfig(speeds: [1, 1.25, 1.5, 2], autoplayNext: true)),
],
);
final session = dw.plugins.media.open(
items: [
DwMediaItem(id: lesson.id, kind: DwMediaKind.video, title: lesson.title,
source: DwMediaSource.resolve(() => fetchSignedUrl(lesson.id))),
],
callbacks: DwMediaCallbacks(onCompleted: (item) => onPositionThreshold(item.id)),
options: const DwMediaOpenOptions(autoplayOnOpen: true),
);
DwMediaItem— one thing to play: a stableid(the queue's key, the resume key, "is this the item playing"), akind(videooraudio), aDwMediaSource, andtitle/artworkUrl/extrasthe package carries without reading.DwMediaSource.url(...)for a plain address,DwMediaSource.resolve(...)for anything that expires: the function is called on every load and again on every retry, so an expired signed link is fetched afresh instead of ending the player.DwMediaCallbacksare given once, atopen(), and cover the whole queue:onStarted,onProgress,onReachedEnd,onCompleted,onError,onItemChanged.
Sessions
open() returns a DwMediaSession: the queue, the engine of its current item, whether it is
fullscreen, minimized and showing its controls. The plugin owns it, not the widget that opened
it — pages come and go and the session plays on, which is what makes the mini-player and
fullscreen possible without a reload. The engine itself is not public: everything goes through the
session. It ends with session.dispose(), or with the mini-player's close.
- One item, one engine.
open()for an item a live session stands on returns that session — the page coming back reattaches to what the mini-player shows instead of loading a second copy — and the new open's request applies to it: its callbacks replace the old ones (a page that is gone hears nothing more), its settings replace the old ones, a different queue replaces the old one around the same item with the engine kept, andautoplayOnOpenplays the item if it is paused or hidden. Three settings only a load applies stay as the item was loaded: the start speed (defaultSpeed), the web's muted start (webMutedStart), and the iOS half ofwakelockWhilePlaying(video_player's display-sleep option is fixed when its controller is created). The mini-player's initial size and position apply only before it first shows. dw.plugins.media.sessionManager.activeis the session the mini-player shows: the one played last, or opened last while nothing plays — opening without autoplay never takes it from a playing session. UndersingleActiveItem, playing a session pauses every other.- Nothing is left headless. Closing the mini-player with
miniPlayerCloseStopsPlayback: falsepauses and hides the session: it stays reachable by opening its item again, and the next session opened for any other item ends it, so no engine outlives the moment it could still be reached. WithminiPlayer: false, the page leaving (minimize()) does whatonLeaveWithoutMiniPlayersays — pause, stop, or keep playing. - A controls widget reads
session.status(DwMediaPlaybackStatus: intent, waiting, play state, error, speed and sound) and a timeline readssession.position(DwMediaPositionState: position, duration, buffered and seek target).session.playbackremains their merge (DwMediaPlaybackState), following the queue. Other views aresession.queue(DwMediaQueueState: items, current index, next-item preview and autoplay countdown),session.isFullscreen,session.minimizedandsession.controlsVisible, allValueListenables — a session is created atopen(), so there is no provider to declare for it ahead of time.session.optionscarries the resolved settings a control reads (speeds,fullscreen). - Web surface handoff preserves play intent. A browser can pause an HTML
video when its platform view is detached and reattached to another surface.
For a video whose play has succeeded, a replacement
DwVideoSurfacearms one restoration after its replacement frame and two further rendered frames, within 500 ms of that surface mounting. This scheduler barrier lets the browser place the platform view; it is a bounded presentation allowance, not a DOM-connected API. It keeps the same controller and source. A web-only platform-event bridge retains typed browser refusal/cancellation for that owned restoration beforevideo_playerdiscards its error code; genuine failures and unowned streams still pass through. The initialization token is claimed once before native creation awaits, so a foreign player created from an owned event callback cannot inherit it. Explicit pause, background pause, seek or session disposal cancels that restoration; initial autoplay refusals are not retried. Position ticks do not arm it, and unrelated later engine pauses do not start a retry loop. If a user resumes while that one native play promise is pending, the newer command waits for settlement and queued errors before issuing its own play; refusal of the newer ordinary play is not swallowed. Pause, seek, source change, disposal or an explicit platform restart invalidates a waiting command. This pending-command wait is capped at 30 seconds, independently of the 500 ms permission to begin restoration. At the cap the session stays paused; no overlapping play or automatic reload is issued. A second surface handoff while that promise is unresolved is rejected without changing its original deadline; if the old play aborts, it stays paused for a manual retry. Manual retry can replace the source, or play can resume after the old promise settles. Presentation, observation and failure-reporting ownership detach immediately on close or source change, including while resume storage waits. Native disposal pauses first, waits for its owned pending play and drains events, pauses again to cancel the SDK's timer, then disposes. This relies on HTML pause aborting a pending play, asvideo_player_webdoes. A broken delegate that never settles cannot be force-disposed safely through the SDK's public API and can delay native cleanup. - Controls visibility is the session's.
controlsVisibleis true with pause intent, and forcontrolsAutoHideDelayafter play intent starts or aftershowControls();toggleControls()is what a tap on the picture calls. The controls themselves are the app's. - Commands:
play,pause,togglePlay,seek,skipBack,skipForward,setSpeed(only a speed fromspeeds; with the list empty it throws — the control is off),setMuted,setVolume,retry,next,previous,jumpTo,cancelAutoplay,enterFullscreen,exitFullscreen,minimize,restore,showControls,hideControls,toggleControls. - The playback state is
loading,ready,playing,paused,endedorerror, with the position, duration, buffered position, speed, volume and mute. Intent owns play/pause:intentisDwMediaPlayIntent.playorpause, changed by commands and end.waitingmeans buffering or unconfirmed play and drives only a spinner. Buttons draw from intent and calltogglePlay(); controls timers and observation do not react to waiting. Redundantplay()does nothing; at an end it seeks to zero and plays.hasReallyPlayedreports real playback of the current item. A seek setsseekTargetand the displayed position immediately. A video position read is accepted only if no seek overlapped it. Seeking away from the end clearsendedimmediately, paused; play during that seek keeps its target and waits for the seek to settle before starting the engine. Stale engine ticks are ignored until arrival within 200 ms or completion. One seek runs and only the latest queued target follows it. Skips count from the displayed target. A timeline thumb reads its active drag, thenseekTarget, then the playback position. An error stays inside the player: the state turnserror,onErrorfires, andretry()re-resolves the source and returns to where playback failed. A retry showsloadingat once, and a tap while any load runs — the first one included — joins it rather than loading twice. A load that does not settle withinloadTimeoutfails the same way, so a joined retry can never hang; the load that overran is dropped, never joined.
Real playback, not a position
Both engines report a seek to the end as "completed": video_player sets isCompleted on any seek
landing on the duration, and just_audio on Android reaches completed after a seek made while
paused. And video_player reports isPlaying the moment play() is called, before the platform
answers — a web play() the browser refused still reads as playing.
So the controller counts only real playback: the engine plays, no seek of its own is in flight, and the position has moved past where playback started or the last seek landed.
onStartedfires on the first real playback of an item — not on a refusedplay().onReachedEndfires when the engine turns "ended" after real playback since the last seek — never on a scrub to the end, a resume point near the end, or a completed event that arrives late after a paused seek.reachedEndTolerancelets a real tick just short of the end count too.- Autoplay follows the same rule, every time rather than once: a seek clears a real end, so a scrub to the end after a real end, a cancelled countdown, or an end by tolerance never moves the queue; playing to the end again does.
onCompletedis a position threshold, not played coverage, and it fires when the position first crossescompletedThreshold, by playback or by a seek. A project that counts a lesson as done when the member drags to the end listens to this one.onProgressand the periodic resume save run on real playback only.
Confirmed played intervals
Opt in with DwMediaConfig(playbackDelivery: savePlayedReport), or the session
option. The package confirms half-open spans when real playback advances beyond
its playback or seek anchor. Its own seeks cannot bridge a gap. Pause, seek, speed,
source change, error and end seal the current window; waiting preserves continuity.
End adds no unobserved tail. Without an adapter, no intervals are retained;
withoutPlaybackDelivery: true disables an inherited adapter. Do not re-derive
spans with position-gap heuristics or manual confirmation hooks.
session.flushPlayback() also seals a nonempty window, capturing item/source
attribution and the adapter, and starts a handled delivery attempt. Empty windows
produce no report. Source/item replacement and disposal seal remaining data once.
DwMediaIntervalAccumulator unions overlap and adjacency. coveredDuration is
unique media coverage; projects union reports across rewatches rather than sum them.
The manager exposes immutable pendingPlaybackReports snapshots and
playbackDeliveryError(report). Automatic delivery errors are handled and retained;
await manager.retryPlaybackReport(report) retries explicitly and surfaces failure.
Concurrent retry joins the same attempt. Only successful acceptance of that exact
batch removes it; late acknowledgement cannot remove newer windows. There are no
implicit retries. Engine/session disposal never awaits an unresolved adapter.
Pending reports remain in this manager's memory for its lifetime. A project needing
durability takes ownership before acknowledging; batch/session/source-generation
identities are manager-local, not backend idempotency keys.
Thresholds, content identity, storage and completion policy belong to projects.
The framework example's workout_playback_coverage.dart accepts package reports
into an in-memory union, including rewatches, without a position observer.
Queue, fullscreen, mini-player
- Autoplay. With
autoplayNext, an item that really ended moves the queue on: at once, or after a countdown (autoplayCountdown) thatqueue.autoplayCountdownshows, updated everyautoplayCountdownTick, andcancelAutoplay()stops. The move comes exactly atautoplayCountdownDuration, fractional or not. The next-item preview (nextPreview) is its own switch, independent of autoplay. - Fullscreen is a route of the package's own. Wrap the inline player in
DwMediaFullscreenHost(session:, builder:, child:): wheneversession.isFullscreenturns true —enterFullscreen(),autoEnterFullscreenOnPlay, already true when the page builds — it pushes the package's route drawingbuilderonto the root navigator, withfullscreenTransitionDurationandfullscreenTransitionBuilder(a fade by default). The route setsfullscreenOrientationsand, only if it set any,exitOrientationswhen it goes; it leaves onexitFullscreen()or a back gesture alike, and the flag changes after the frame, so a page listening to it rebuilds safely. The route itself is not exported: the host is the one way in, so the flag and the route cannot disagree. A request waits for its host:enterFullscreen()orautoEnterFullscreenOnPlaybefore any host for the session is mounted — autoplay on open playing before the page has built, a call from the page's owninitState— is kept, and the first host that mounts honours it;isFullscreenturns true only then. From the mini-player (the page has calledminimize()) a request does nothing, rather than leave anything claiming fullscreen with no page to show it. The queue moving on keeps it up (keepFullscreenAcrossItems). System bars are the app's: its fullscreen page sets them if it wants them hidden. - The mini-player is mounted once, at the root —
DwMiniPlayerHost(sessionManager:, builder:, onExpand:)inMaterialApp.builder. It shows the active session whilesession.minimizedis true; thebuilderdraws it, andonExpand(item)navigates to the app's own player page — the package has no opinion on routes. The player page callssession.minimize()when it goes andsession.restore()when it comes back, both safe frominitState/dispose; so isopen(), which returns the session at once and changesactive— and, opening an item it already holds, that session's queue and playback — after the frame.play(),pause(),seek()andjumpTo()are commands for after the frame (a tap, a callback), not for a build. It sits above the navigator, where there is no overlay: its buttons cannot carry tooltips. - The mini-player stays where it is let go. A drag moves it inside the visible area — the
viewport less the system padding — and it stays there;
miniPlayerSnapToEdgessettles it on an edge instead. A free player is what a person expects of a window they moved; snapping suits a phone layout that wants the content uncovered, and is one setting away. When the window shrinks the player is pushed in, but the place it was put is kept: growing the window brings it back. - Its size is a width; the height follows the video's own aspect ratio, so the picture is never
letterboxed or stretched — 240 wide at first, between 160 and 60 % of the viewport, so on a
375 px phone it still resizes from 160 to 225. A pinch resizes it, and so — while a mouse, or a
stylus that hovers, is connected (
miniPlayerResize: pointer; Flutter'sMouseTrackercounts both) — does dragging the handle in the corner opposite the one it is anchored to: in the right half of the screen it is anchored right, so the handle is on its left, and the anchored corner stays put while the handle follows the pointer. A touch screen has no handle by default — a corner the size of a fingertip would swallow the chrome's buttons. Position and size live in the host, so they survive route changes and the player hiding and showing again. While a resize runs the cursor is the resize one over the whole player, its buttons included; outside the player it is not held. For a screen reader the handle's increase and decrease actions step the width by the same rule as the drag — an action that would change nothing is not offered; its label is the app's,DwMiniPlayerHost(resizeHandleLabel:). - The chrome is the app's, handle included. The host owns the handle's square (a press in it
resizes and never reaches the chrome) and the cursor over it; the mark the person sees is drawn
by the chrome, at
DwMiniPlayerHost.resizeCornerOf(context)—nullwhile there is no handle — which is also how the chrome keeps its own buttons off that corner. The example's chrome puts a scrim under its controls so they read over a light frame. DwVideoSurface(session:)draws the current video at its own aspect ratio — the one widget the package draws, a texture with no chrome.
Background, the screen, the web
- On the app going to the background —
hidden,paused,detached, neverinactive, which a permission prompt or a system sheet raises too — positions are saved (saveOnBackground), and a video (pauseVideoInBackground), or audio unlessbackgroundAudiois on, is stopped whatever it was doing: playing, buffering, waiting to load to play, or counting down to the next item (the countdown is cancelled). Nothing starts in the background:play()and autoplay are refused there. Coming back resumes nothing on its own.video_player's own background rule, which would pause and resume by itself, is switched off so that these settings decide. - Background audio needs the platform's permission too. iOS:
audioinUIBackgroundModesinInfo.plist. Android: nothing to declare for playback with the screen locked while the app lives; a notification with controls and a foreground service are not part of the package. - The screen stays on while a video plays (
wakelockWhilePlaying), throughwakelock_plusand, on iOS,video_player's own option; two sessions playing at once share one lock, and a failed video lets it go. - The web. A browser refuses to start sound without a gesture, so a video starts muted
(
webMutedStart) until the person turns the sound on; withrememberSound, that choice carries to every item after it. On the web only, and by the exactDOMExceptionnamevideo_player_webreports: aplay()refused withNotAllowedErrorleaves the item paused, not in error, and anAbortError— a request the player itself superseded — is ignored. Everything else, and anything off the web, is an error state withonError. A failurevideo_playerrecords in its own value carries only a message, and is an error too:retry()recovers it. An item's engine is released only after the frame that removed its video from the page — disposing a video still on the page throws in the browser.
Every setting
Each row is a DwMediaConfig field with its default; each is overridden per session by the field
of the same name on DwMediaOpenOptions (withoutResume: true turns resume off for one session).
The package's tests cover every row, default against changed, in
packages/dartway_media_flutter/test/dw_media_settings_*_test.dart.
| Setting | Default | What it changes |
|---|---|---|
autoplayOnOpen | false | open() plays the first item as soon as it loads |
autoplayNext | false | an item that really ended moves the queue to the next one |
autoplayCountdown | true | with autoplayNext, the move waits for a countdown; off, it is immediate |
autoplayCountdownDuration | 5 s | how long that countdown is; the move comes exactly then |
autoplayCountdownTick | 1 s | how often the countdown's remaining time updates; zero shows only the start |
nextPreview | false | the queue announces the next item before the current one ends |
nextPreviewLeadTime | 10 s | how long before the end the announcement comes |
completedThreshold | 0.9 | the fraction of the duration at which onCompleted fires |
reachedEndTolerance | 500 ms | how close to the end a real tick counts as the end; zero leaves only the engine's event |
progressInterval | 1 s | the shortest gap between two onProgress calls |
speeds | [] | the speeds setSpeed accepts and the controls offer; empty turns speed off and every item plays at defaultSpeed |
defaultSpeed | 1.0 | the speed an item starts at; with speeds non-empty it must be one of them (asserted) |
rememberSpeedAcrossItems | true | a chosen speed stays for the next items; off, each starts at defaultSpeed |
skipBack / skipForward | 10 s / 10 s | how far skipBack() and skipForward() move |
singleActiveItem | true | opening or playing a session pauses every other; off, several play at once |
autoRetryCount | 0 | how many times a failed load retries itself before the error state |
autoRetryDelay | 3 s | the wait before each of those retries |
loadTimeout | 30 s | how long one load (resolving the source and opening it) may take before it is an error with onError; null — per open withoutLoadTimeout: true — waits for ever |
controlsAutoHideDelay | 3 s | how long controlsVisible stays true while playing untouched; zero never hides |
playbackDelivery | null (disabled) | optional confirmed-report acceptance adapter; per-session withoutPlaybackDelivery disables it |
resume | DwMediaResumePolicy() | the resume policy; null reads and writes no positions |
resume.saveInterval | 5 s | how often the position is saved during real playback |
resume.minimum | 5 s | a position under this is never saved |
resume.clearPastFraction | 0.9 | past this fraction the saved position is cleared, not updated |
resume.saveOnPause | true | pause() saves |
resume.saveOnDispose | true | ending the item or the session saves |
resume.saveOnBackground | true | the app going to the background saves |
positionStore | in memory | where positions live; pass one over dw.plugins.prefs or the project's storage to resume across launches |
pauseVideoInBackground | true | a playing video pauses in the background |
backgroundAudio | false | playing audio keeps playing in the background (platform setup above) |
wakelockWhilePlaying | true | the screen stays on while a video plays |
webMutedStart | true | on the web, a video starts muted until the person turns the sound on |
rememberSound | true | the last mute choice carries to every item opened after it |
fullscreen | true | whether a session may go fullscreen at all |
fullscreenOrientations | both landscapes | orientations while fullscreen; empty leaves them alone |
exitOrientations | portrait up | orientations set when fullscreen ends; empty allows every one |
autoEnterFullscreenOnPlay | false | playing a video goes fullscreen |
keepFullscreenAcrossItems | true | the queue moving on stays fullscreen; off, it leaves |
fullscreenTransitionDuration | 200 ms | how long the fullscreen route takes to come and go |
fullscreenTransitionBuilder | null (a fade) | the fullscreen route's transition |
fallbackAspectRatio | 16 / 9 | the ratio DwVideoSurface keeps while a video reports no size |
miniPlayer | true | whether minimize() hands the session to the mini-player |
miniPlayerInitialWidth | 240 | the mini-player's width before any resize, held within the bounds below; the height follows the video's ratio |
miniPlayerInitialAlignment | bottom right | where it first appears |
miniPlayerMinWidth / miniPlayerMaxWidthFraction | 160 / 0.6 | resize bounds: a width in pixels, and a fraction of the viewport width (never under the minimum, never past the viewport) |
miniPlayerResize | pointer | a pinch, plus a corner handle while a mouse or a hovering stylus is connected; always shows the handle on touch too, never turns resizing off |
miniPlayerResizeHandleExtent | 32 | the side of the handle's square, in logical pixels |
miniPlayerSnapToEdges | false | a released mini-player settles on an edge; off, it stays where it was released |
miniPlayerSnapEdges | horizontal | which edges: the nearer side (horizontal) or the nearest of four (all) |
miniPlayerSnapThreshold | infinite | how close to that edge, in logical pixels, it must be released to settle |
miniPlayerCloseStopsPlayback | true | its close ends the session; off, it pauses and hides it (reachable by opening its item, ended by the next other open) |
onLeaveWithoutMiniPlayer | pause | with miniPlayer off, what the page leaving does: pause, stop or keepPlaying |
Testing
package:dartway_media_flutter/testing.dart plays items in a widget test without a plugin for any
platform:
late DwFakeVideoPlayerPlatform video;
setUp(() {
video = DwFakeVideoPlayerPlatform.install(); // every video loads at once, a minute long
DwFakeJustAudioPlatform.install();
});
- Every video opened is a
DwFakeVideoinvideo.videos/video.latest:advanceTois real playback (read onvideo_player's next 100 ms poll),finishthe platform's own end,faila failure mid-play,startBuffering/stopBuffering,refusePlayWith/refuseSeekWitha refused command;readyOnOpen: falseholds each load until the test callsready. - Every track is a
DwFakeAudiointracks/latest, withadvanceTo,finishandfail; a seek to its end reports it completed, as Android does.just_audiofinishes part of its work outside a widget test's fake clock, so after an audio command a test callsdwSettleMedia(tester)rather thanpump. - A scrub is a
session.seek(...); real playback isadvanceToafterplay(). A test of "a scrub to the end must not count" is those two lines apart. - A playing video polls its position on a timer: end the sessions (
dw.plugins.media.dispose()) before a test finishes.
Not in the package
OS picture-in-picture, lock-screen and notification controls, HLS quality, subtitles, and offline
files (with dartway_offline when it returns, dartway/dartway#262).