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.playback(the current item'sDwMediaPlaybackState, following the queue),session.queue(DwMediaQueueState: the items, the current index, the next-item preview and the 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). - Controls visibility is the session's.
controlsVisibleis true while the item is not playing, and forcontrolsAutoHideDelayafter playback starts or aftershowControls();toggleControls()is what a tap on the picture calls. The controls themselves are the app's. - Commands:
play,pause,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,buffering,playing,paused,endedorerror, with the position, duration, buffered position, speed, volume and mute. 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 (explicit input)
The existing progress/end guards serve callbacks, not an interval proof. A progress position after a seek can be far from the last callback. Default video/audio players remain unverified for automatic accounting: position movement records no coverage. Only a caller capable of independently confirming a span may use this opt-in API. It must account for platform discontinuities the framework cannot observe.
// Configure a project adapter. Success means it durably accepted this exact batch.
final config = DwMediaConfig(playbackDelivery: saveConfirmedReport);
// A capable observer captures this before the span, not after a seek/pause.
final handle = session.playbackObservation;
// After independently confirming [start, end), forward the same handle:
final result = handle.record(DwMediaPlayedInterval(start, end));
final report = session.flushPlayback(); // null if no confirmed intervals
DwMediaPlayedInterval requires nonnegative start and end greater than start.
DwMediaIntervalAccumulator merges overlap and adjacency into sorted half-open
intervals; coveredDuration is unique media coverage, not elapsed/repeated listening
or wall-clock time. Reports copy this immutable union. Rewatch spans may recur in
later windows; project consumers union reports rather than summing their durations.
A handle is bound to one session, item and actual source-load generation. Reload,
queue movement, replacement and disposal reject old handles. Pause, buffering,
seek, speed and background boundaries also invalidate previously captured handles;
known loading/error/non-playing states reject recording. Results are recorded,
disabled, stale or notPlaying. State guards cannot prove a span. Acquire a new
handle only for a newly confirmed span; never interpolate missed samples or fill
an end tolerance. Fullscreen and mini-player share the session. Item identity must
mean stable content in the project; reopening that same item preserves its engine,
while retry reloads the source (an expiring URL is not content identity).
playbackDelivery is optional on config and open options; without it, recording is
disabled. withoutPlaybackDelivery: true disables an inherited adapter. A nonempty
explicit flush seals a report into manager-owned pending state before opening a new
window and starts one delivery attempt. Source/item replacement, end and disposal
seal remaining confirmed data once. End adds no unobserved tail. Empty windows do
not create reports. Sealing captures both attribution and adapter, so reopening a
page cannot redirect an old report.
The manager exposes immutable pendingPlaybackReports snapshots and
playbackDeliveryError(report). Automatic delivery errors are handled and retained;
await manager.retryPlaybackReport(report) explicitly retries 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 finishes without awaiting an unresolved
adapter. Pending reports survive engine teardown only in this manager's memory,
not process/plugin teardown. A project needing durability must take ownership before
acknowledging; hung adapters and retained failures remain inspectable pending work.
Batch/session/source-generation identities are stable only within a manager; project
adapters define backend idempotency and durable identity. Lesson thresholds (for
example 70%), storage, request DTOs and completion decisions belong to projects.
The example's workout_playback_coverage.dart forwards explicit confirmed input and
acknowledges an in-memory union only. Its default player never supplies such input.
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).