Skip to main content

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 stable id (the queue's key, the resume key, "is this the item playing"), a kind (video or audio), a DwMediaSource, and title/artworkUrl/ extras the 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.
  • DwMediaCallbacks are given once, at open(), 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, and autoplayOnOpen plays 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 of wakelockWhilePlaying (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.active is 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. Under singleActiveItem, playing a session pauses every other.
  • Nothing is left headless. Closing the mini-player with miniPlayerCloseStopsPlayback: false pauses 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. With miniPlayer: false, the page leaving (minimize()) does what onLeaveWithoutMiniPlayer says — pause, stop, or keep playing.
  • A controls widget reads session.status (DwMediaPlaybackStatus: intent, waiting, play state, error, speed and sound) and a timeline reads session.position (DwMediaPositionState: position, duration, buffered and seek target). session.playback remains their merge (DwMediaPlaybackState), following the queue. Other views are session.queue (DwMediaQueueState: items, current index, next-item preview and autoplay countdown), session.isFullscreen, session.minimized and session.controlsVisible, all ValueListenables — a session is created at open(), so there is no provider to declare for it ahead of time. session.options carries 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 DwVideoSurface arms 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 before video_player discards 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, as video_player_web does. 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. controlsVisible is true with pause intent, and for controlsAutoHideDelay after play intent starts or after showControls(); 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 from speeds; 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, ended or error, with the position, duration, buffered position, speed, volume and mute. Intent owns play/pause: intent is DwMediaPlayIntent.play or pause, changed by commands and end. waiting means buffering or unconfirmed play and drives only a spinner. Buttons draw from intent and call togglePlay(); controls timers and observation do not react to waiting. Redundant play() does nothing; at an end it seeks to zero and plays. hasReallyPlayed reports real playback of the current item. A seek sets seekTarget and the displayed position immediately. A video position read is accepted only if no seek overlapped it. Seeking away from the end clears ended immediately, 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, then seekTarget, then the playback position. An error stays inside the player: the state turns error, onError fires, and retry() re-resolves the source and returns to where playback failed. A retry shows loading at once, and a tap while any load runs — the first one included — joins it rather than loading twice. A load that does not settle within loadTimeout fails 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.

  • onStarted fires on the first real playback of an item — not on a refused play().
  • onReachedEnd fires 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. reachedEndTolerance lets 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.
  • onCompleted is a position threshold, not played coverage, and it fires when the position first crosses completedThreshold, 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.
  • onProgress and 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) that queue.autoplayCountdown shows, updated every autoplayCountdownTick, and cancelAutoplay() stops. The move comes exactly at autoplayCountdownDuration, 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:): whenever session.isFullscreen turns true — enterFullscreen(), autoEnterFullscreenOnPlay, already true when the page builds — it pushes the package's route drawing builder onto the root navigator, with fullscreenTransitionDuration and fullscreenTransitionBuilder (a fade by default). The route sets fullscreenOrientations and, only if it set any, exitOrientations when it goes; it leaves on exitFullscreen() 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() or autoEnterFullscreenOnPlay before any host for the session is mounted — autoplay on open playing before the page has built, a call from the page's own initState — is kept, and the first host that mounts honours it; isFullscreen turns true only then. From the mini-player (the page has called minimize()) 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:) in MaterialApp.builder. It shows the active session while session.minimized is true; the builder draws it, and onExpand(item) navigates to the app's own player page — the package has no opinion on routes. The player page calls session.minimize() when it goes and session.restore() when it comes back, both safe from initState/dispose; so is open(), which returns the session at once and changes active — and, opening an item it already holds, that session's queue and playback — after the frame. play(), pause(), seek() and jumpTo() 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; miniPlayerSnapToEdges settles 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's MouseTracker counts 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) — null while 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, never inactive, which a permission prompt or a system sheet raises too — positions are saved (saveOnBackground), and a video (pauseVideoInBackground), or audio unless backgroundAudio is 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: audio in UIBackgroundModes in Info.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), through wakelock_plus and, 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; with rememberSound, that choice carries to every item after it. On the web only, and by the exact DOMException name video_player_web reports: a play() refused with NotAllowedError leaves the item paused, not in error, and an AbortError — a request the player itself superseded — is ignored. Everything else, and anything off the web, is an error state with onError. A failure video_player records 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.

SettingDefaultWhat it changes
autoplayOnOpenfalseopen() plays the first item as soon as it loads
autoplayNextfalsean item that really ended moves the queue to the next one
autoplayCountdowntruewith autoplayNext, the move waits for a countdown; off, it is immediate
autoplayCountdownDuration5 show long that countdown is; the move comes exactly then
autoplayCountdownTick1 show often the countdown's remaining time updates; zero shows only the start
nextPreviewfalsethe queue announces the next item before the current one ends
nextPreviewLeadTime10 show long before the end the announcement comes
completedThreshold0.9the fraction of the duration at which onCompleted fires
reachedEndTolerance500 mshow close to the end a real tick counts as the end; zero leaves only the engine's event
progressInterval1 sthe 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
defaultSpeed1.0the speed an item starts at; with speeds non-empty it must be one of them (asserted)
rememberSpeedAcrossItemstruea chosen speed stays for the next items; off, each starts at defaultSpeed
skipBack / skipForward10 s / 10 show far skipBack() and skipForward() move
singleActiveItemtrueopening or playing a session pauses every other; off, several play at once
autoRetryCount0how many times a failed load retries itself before the error state
autoRetryDelay3 sthe wait before each of those retries
loadTimeout30 show 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
controlsAutoHideDelay3 show long controlsVisible stays true while playing untouched; zero never hides
playbackDeliverynull (disabled)optional confirmed-report acceptance adapter; per-session withoutPlaybackDelivery disables it
resumeDwMediaResumePolicy()the resume policy; null reads and writes no positions
resume.saveInterval5 show often the position is saved during real playback
resume.minimum5 sa position under this is never saved
resume.clearPastFraction0.9past this fraction the saved position is cleared, not updated
resume.saveOnPausetruepause() saves
resume.saveOnDisposetrueending the item or the session saves
resume.saveOnBackgroundtruethe app going to the background saves
positionStorein memorywhere positions live; pass one over dw.plugins.prefs or the project's storage to resume across launches
pauseVideoInBackgroundtruea playing video pauses in the background
backgroundAudiofalseplaying audio keeps playing in the background (platform setup above)
wakelockWhilePlayingtruethe screen stays on while a video plays
webMutedStarttrueon the web, a video starts muted until the person turns the sound on
rememberSoundtruethe last mute choice carries to every item opened after it
fullscreentruewhether a session may go fullscreen at all
fullscreenOrientationsboth landscapesorientations while fullscreen; empty leaves them alone
exitOrientationsportrait uporientations set when fullscreen ends; empty allows every one
autoEnterFullscreenOnPlayfalseplaying a video goes fullscreen
keepFullscreenAcrossItemstruethe queue moving on stays fullscreen; off, it leaves
fullscreenTransitionDuration200 mshow long the fullscreen route takes to come and go
fullscreenTransitionBuildernull (a fade)the fullscreen route's transition
fallbackAspectRatio16 / 9the ratio DwVideoSurface keeps while a video reports no size
miniPlayertruewhether minimize() hands the session to the mini-player
miniPlayerInitialWidth240the mini-player's width before any resize, held within the bounds below; the height follows the video's ratio
miniPlayerInitialAlignmentbottom rightwhere it first appears
miniPlayerMinWidth / miniPlayerMaxWidthFraction160 / 0.6resize bounds: a width in pixels, and a fraction of the viewport width (never under the minimum, never past the viewport)
miniPlayerResizepointera 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
miniPlayerResizeHandleExtent32the side of the handle's square, in logical pixels
miniPlayerSnapToEdgesfalsea released mini-player settles on an edge; off, it stays where it was released
miniPlayerSnapEdgeshorizontalwhich edges: the nearer side (horizontal) or the nearest of four (all)
miniPlayerSnapThresholdinfinitehow close to that edge, in logical pixels, it must be released to settle
miniPlayerCloseStopsPlaybacktrueits close ends the session; off, it pauses and hides it (reachable by opening its item, ended by the next other open)
onLeaveWithoutMiniPlayerpausewith 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 DwFakeVideo in video.videos / video.latest: advanceTo is real playback (read on video_player's next 100 ms poll), finish the platform's own end, fail a failure mid-play, startBuffering / stopBuffering, refusePlayWith / refuseSeekWith a refused command; readyOnOpen: false holds each load until the test calls ready.
  • Every track is a DwFakeAudio in tracks / latest, with advanceTo, finish and fail; a seek to its end reports it completed, as Android does. just_audio finishes part of its work outside a widget test's fake clock, so after an audio command a test calls dwSettleMedia(tester) rather than pump.
  • A scrub is a session.seek(...); real playback is advanceTo after play(). 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).