video kit: VideoPlayerAdapter seam, LeafVideoPage lifecycle (active/route/lifecycle gating, prebuffer, retry), LeafVideoControls
This commit is contained in:
1 parent
3cc02c9bbe
commit
bdbd3090f1
5 files changed
+763
No files matched your search
@@ -7,6 +7,9 @@ export 'src/misc/animated_favorite_button.dart';
|
||||
export 'src/misc/animated_star.dart';
|
||||
export 'src/misc/leaf_async_chip.dart';
|
||||
export 'src/popover/leaf_popover.dart';
|
||||
export 'src/video/leaf_video_adapter.dart';
|
||||
export 'src/video/leaf_video_controls.dart';
|
||||
export 'src/video/leaf_video_page.dart';
|
||||
export 'src/viewer/leaf_chrome.dart';
|
||||
export 'src/viewer/leaf_viewer.dart';
|
||||
export 'src/viewer/leaf_zoom_controller.dart';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Player-agnostic contract between [LeafVideoPage]/[LeafVideoControls] and
|
||||
/// whichever player the host app wires in (a Media3 wrapper, media_kit, ...).
|
||||
/// This is the only seam that knows a player exists; implementations live
|
||||
/// app-side so the package stays dependency-free.
|
||||
abstract class VideoPlayerAdapter {
|
||||
/// The player's rendering surface, inserted into the page's stack.
|
||||
Widget get surface;
|
||||
|
||||
/// Loads [url]. Completes when the player may start playback; throws on
|
||||
/// load failure. Safe to call before the surface is laid out.
|
||||
Future<void> load({required String url, Map<String, String>? headers});
|
||||
|
||||
Stream<Duration> get positionStream;
|
||||
Stream<Duration> get bufferStream;
|
||||
Stream<bool> get playingStream;
|
||||
|
||||
/// Fires when the first frame is actually rendered — the safe signal to
|
||||
/// drop the thumbnail (a `loaded` state is not; it precedes the first
|
||||
/// frame and produces a black flash).
|
||||
Stream<void> get readyStream;
|
||||
|
||||
/// Fires on playback errors after a successful [load].
|
||||
Stream<Object> get errorStream;
|
||||
|
||||
Duration get position;
|
||||
Duration get duration;
|
||||
Duration get buffer;
|
||||
bool get isPlaying;
|
||||
|
||||
void play();
|
||||
void pause();
|
||||
void playOrPause();
|
||||
void seek(Duration target);
|
||||
|
||||
/// Releases the underlying player. The adapter is unusable afterwards.
|
||||
void dispose();
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../viewer/leaf_viewer.dart';
|
||||
import '../viewer/leaf_viewer_internal.dart';
|
||||
import 'leaf_video_adapter.dart';
|
||||
|
||||
enum _SkipDirection { backward, forward }
|
||||
|
||||
/// Default touch-first control set for [LeafVideoPage]: double-tap thirds to
|
||||
/// seek (middle toggles playback), single tap toggles the viewer's chrome,
|
||||
/// bottom bar with scrubber and a fullscreen slot.
|
||||
///
|
||||
/// Visibility is the viewer's chrome state, never owned here — this widget
|
||||
/// only renders. Outside a chrome-enabled viewer it renders permanently
|
||||
/// visible and single taps do nothing.
|
||||
class LeafVideoControls extends StatefulWidget {
|
||||
const LeafVideoControls({super.key, required this.adapter, this.skipDuration = const Duration(seconds: 10), this.onFullscreenToggle});
|
||||
|
||||
final VideoPlayerAdapter adapter;
|
||||
final Duration skipDuration;
|
||||
|
||||
/// Null hides the fullscreen button.
|
||||
final VoidCallback? onFullscreenToggle;
|
||||
|
||||
@override
|
||||
State<LeafVideoControls> createState() => _LeafVideoControlsState();
|
||||
}
|
||||
|
||||
class _LeafVideoControlsState extends State<LeafVideoControls> {
|
||||
static const _feedbackDuration = Duration(milliseconds: 600);
|
||||
|
||||
Offset? _doubleTapPos;
|
||||
Timer? _skipFeedbackTimer;
|
||||
_SkipDirection? _skipFeedback;
|
||||
Duration? _skipAmount;
|
||||
|
||||
bool get _mobile => !leafIsDesktopPlatform();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_skipFeedbackTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
LeafViewer.chromeOf(context)?.toggle();
|
||||
}
|
||||
|
||||
void _handleDoubleTapDown(TapDownDetails details) {
|
||||
_doubleTapPos = details.localPosition;
|
||||
}
|
||||
|
||||
void _handleDoubleTap() {
|
||||
if (_doubleTapPos == null) return;
|
||||
final adapter = widget.adapter;
|
||||
if (!_mobile) {
|
||||
adapter.playOrPause();
|
||||
return;
|
||||
}
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
final third = box.size.width / 3;
|
||||
final x = _doubleTapPos!.dx;
|
||||
final skip = widget.skipDuration;
|
||||
|
||||
if (x < third) {
|
||||
final target = adapter.position - skip;
|
||||
adapter.seek(target < Duration.zero ? Duration.zero : target);
|
||||
_accumulate(_SkipDirection.backward, skip);
|
||||
} else if (x > third * 2) {
|
||||
final target = adapter.position + skip;
|
||||
adapter.seek(target > adapter.duration ? adapter.duration : target);
|
||||
_accumulate(_SkipDirection.forward, skip);
|
||||
} else {
|
||||
adapter.playOrPause();
|
||||
}
|
||||
}
|
||||
|
||||
void _accumulate(_SkipDirection direction, Duration skip) {
|
||||
final amount = _skipFeedback == direction ? _skipAmount! + skip : skip;
|
||||
_skipFeedbackTimer?.cancel();
|
||||
setState(() {
|
||||
_skipFeedback = direction;
|
||||
_skipAmount = amount;
|
||||
});
|
||||
_skipFeedbackTimer = Timer(_feedbackDuration, () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_skipFeedback = null;
|
||||
_skipAmount = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chrome = LeafViewer.chromeOf(context);
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _handleTap,
|
||||
onDoubleTapDown: _handleDoubleTapDown,
|
||||
onDoubleTap: _handleDoubleTap,
|
||||
),
|
||||
),
|
||||
// Bottom bar — fades with the viewer's chrome state.
|
||||
if (chrome != null)
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: chrome.visibleListenable,
|
||||
builder: (context, visible, _) => IgnorePointer(
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(duration: const Duration(milliseconds: 200), opacity: visible ? 1.0 : 0.0, child: _buildBar(context)),
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildBar(context),
|
||||
// Center play badge while paused.
|
||||
Center(
|
||||
child: IgnorePointer(
|
||||
child: StreamBuilder<bool>(
|
||||
stream: widget.adapter.playingStream,
|
||||
initialData: widget.adapter.isPlaying,
|
||||
builder: (context, snapshot) {
|
||||
final playing = snapshot.data ?? true;
|
||||
return AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.fastEaseInToSlowEaseOut,
|
||||
opacity: playing ? 0 : 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: Colors.black38, borderRadius: BorderRadius.circular(100)),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: const Icon(Icons.play_arrow, size: 40),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_skipFeedback != null && _skipAmount != null)
|
||||
IgnorePointer(
|
||||
child: Align(
|
||||
alignment: _skipFeedback == _SkipDirection.backward ? Alignment.centerLeft : Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(100)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_skipFeedback == _SkipDirection.backward ? Icons.fast_rewind : Icons.fast_forward, size: 20),
|
||||
const SizedBox(width: 4),
|
||||
Text('${_skipFeedback == _SkipDirection.forward ? '+' : '-'}${_skipAmount!.inSeconds}s'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBar(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(colors: [Colors.black, Colors.transparent], begin: Alignment.bottomCenter, end: Alignment.topCenter),
|
||||
),
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 50),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _Scrubber(widget.adapter)),
|
||||
StreamBuilder<Duration>(
|
||||
stream: widget.adapter.positionStream,
|
||||
builder: (context, _) => Text(
|
||||
'${_formatDuration(widget.adapter.position)} / ${_formatDuration(widget.adapter.duration)}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
if (widget.onFullscreenToggle != null) IconButton(icon: const Icon(Icons.fullscreen), onPressed: widget.onFullscreenToggle),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Scrubber extends StatefulWidget {
|
||||
const _Scrubber(this.adapter);
|
||||
|
||||
final VideoPlayerAdapter adapter;
|
||||
|
||||
@override
|
||||
State<_Scrubber> createState() => _ScrubberState();
|
||||
}
|
||||
|
||||
class _ScrubberState extends State<_Scrubber> {
|
||||
bool _wasPaused = false;
|
||||
|
||||
double _fraction(Duration value) {
|
||||
final duration = widget.adapter.duration;
|
||||
return duration.inMilliseconds > 0 ? (value.inMilliseconds / duration.inMilliseconds).clamp(0.0, 1.0) : 0.0;
|
||||
}
|
||||
|
||||
void _seekFraction(double fraction) {
|
||||
widget.adapter.seek(Duration(milliseconds: (widget.adapter.duration.inMilliseconds * fraction).round()));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const height = 6.0;
|
||||
const radius = Radius.circular(3);
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (details) => _seekFraction(details.localPosition.dx / (context.findRenderObject() as RenderBox).size.width),
|
||||
onHorizontalDragStart: (_) {
|
||||
_wasPaused = !widget.adapter.isPlaying;
|
||||
widget.adapter.pause();
|
||||
},
|
||||
onHorizontalDragUpdate: (details) => _seekFraction(details.localPosition.dx / (context.findRenderObject() as RenderBox).size.width),
|
||||
onHorizontalDragEnd: (_) {
|
||||
if (!_wasPaused) widget.adapter.play();
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 20,
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
return SizedBox(
|
||||
height: height,
|
||||
width: width,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(radius),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(color: Colors.white12),
|
||||
StreamBuilder<Duration>(
|
||||
stream: widget.adapter.bufferStream,
|
||||
builder: (context, _) => AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.fastEaseInToSlowEaseOut,
|
||||
width: (_fraction(widget.adapter.buffer) * width).clamp(0.0, width),
|
||||
color: Colors.white30,
|
||||
),
|
||||
),
|
||||
StreamBuilder<Duration>(
|
||||
stream: widget.adapter.positionStream,
|
||||
builder: (context, _) => AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.fastEaseInToSlowEaseOut,
|
||||
width: (_fraction(widget.adapter.position) * width).clamp(0.0, width),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatDuration(Duration d) {
|
||||
final h = d.inHours;
|
||||
final mm = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final ss = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return h > 0 ? '$h:$mm:$ss' : '$mm:$ss';
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../viewer/leaf_chrome.dart';
|
||||
import '../viewer/leaf_viewer.dart';
|
||||
import '../viewer/leaf_viewer_internal.dart';
|
||||
import 'leaf_video_adapter.dart';
|
||||
import 'leaf_video_controls.dart';
|
||||
|
||||
/// A video page for a [LeafViewer]: owns the full player lifecycle so host
|
||||
/// apps don't rewrite it per project.
|
||||
///
|
||||
/// Playback gating — the part every app gets wrong:
|
||||
/// - built as a neighbour page → loads and buffers, stays paused (prebuffer)
|
||||
/// - becomes the settled page → plays
|
||||
/// - stops being it, or the route gets covered, or the app backgrounds → pauses
|
||||
/// - uncovers / returns → resumes only if it was playing before; re-activating
|
||||
/// (swiping back) always resumes, TikTok-style
|
||||
///
|
||||
/// Chrome policy on a chrome-enabled viewer: while paused, chrome is held up
|
||||
/// (and shown on the pause transition); while playing, the viewer's auto-hide
|
||||
/// timer runs.
|
||||
class LeafVideoPage extends StatefulWidget {
|
||||
const LeafVideoPage({
|
||||
super.key,
|
||||
required this.createAdapter,
|
||||
required this.url,
|
||||
this.headers,
|
||||
this.aspectRatio,
|
||||
this.thumbnail,
|
||||
this.controlsBuilder,
|
||||
this.skipDuration = const Duration(seconds: 10),
|
||||
this.onFullscreenToggle,
|
||||
});
|
||||
|
||||
/// Creates the (unloaded) adapter. Called once per page state; the page owns
|
||||
/// and disposes it. Route by codec here — the closure can capture the post.
|
||||
final VideoPlayerAdapter Function() createAdapter;
|
||||
|
||||
final String url;
|
||||
final Map<String, String>? headers;
|
||||
|
||||
/// Video aspect ratio; when set, the surface and thumbnail are centered at
|
||||
/// that ratio while gestures keep working across the whole page.
|
||||
final double? aspectRatio;
|
||||
|
||||
/// Shown until [VideoPlayerAdapter.readyStream] fires.
|
||||
final Widget? thumbnail;
|
||||
|
||||
/// Defaults to [LeafVideoControls].
|
||||
final Widget Function(BuildContext context, VideoPlayerAdapter adapter)? controlsBuilder;
|
||||
final Duration skipDuration;
|
||||
|
||||
/// Passed through to the default controls; null hides the button. Provide
|
||||
/// it only for videos where fullscreen makes sense (landscape ones).
|
||||
final VoidCallback? onFullscreenToggle;
|
||||
|
||||
@override
|
||||
State<LeafVideoPage> createState() => _LeafVideoPageState();
|
||||
}
|
||||
|
||||
class _LeafVideoPageState extends State<LeafVideoPage> with WidgetsBindingObserver {
|
||||
late final VideoPlayerAdapter _adapter;
|
||||
bool _loaded = false;
|
||||
bool _failed = false;
|
||||
bool _ready = false;
|
||||
|
||||
bool _active = true;
|
||||
bool _obscured = false;
|
||||
bool _backgrounded = false;
|
||||
bool _wasCovered = false;
|
||||
bool _wasPlayingBeforeCover = false;
|
||||
|
||||
LeafViewerChrome? _chrome;
|
||||
int? _pageIndex;
|
||||
ValueListenable<int>? _currentIndex;
|
||||
Animation<double>? _routeAnimation;
|
||||
final _subs = <StreamSubscription<void>>[];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_adapter = widget.createAdapter();
|
||||
_subs.add(_adapter.playingStream.listen(_onPlayingChanged));
|
||||
_subs.add(_adapter.readyStream.listen((_) {
|
||||
if (mounted) setState(() => _ready = true);
|
||||
}));
|
||||
_subs.add(_adapter.errorStream.listen((error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _failed = true);
|
||||
_adapter.pause();
|
||||
}));
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
await _adapter.load(url: widget.url, headers: widget.headers);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
setState(() => _failed = true);
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _loaded = true);
|
||||
if (_active && !_obscured && !_backgrounded) _adapter.play();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_chrome = LeafViewer.chromeOf(context);
|
||||
_pageIndex = LeafPageIndexScope.maybeOf(context);
|
||||
|
||||
final currentIndex = LeafViewer.currentIndexListOf(context);
|
||||
if (!identical(currentIndex, _currentIndex)) {
|
||||
_currentIndex?.removeListener(_onIndexChanged);
|
||||
_currentIndex = currentIndex;
|
||||
_currentIndex?.addListener(_onIndexChanged);
|
||||
_evaluateActive();
|
||||
}
|
||||
|
||||
final route = ModalRoute.of(context);
|
||||
final animation = route?.secondaryAnimation;
|
||||
if (!identical(animation, _routeAnimation)) {
|
||||
_routeAnimation?.removeStatusListener(_onRouteStatus);
|
||||
_routeAnimation = animation;
|
||||
_routeAnimation?.addStatusListener(_onRouteStatus);
|
||||
_setObscured(_routeAnimation?.status != null && _routeAnimation!.status != AnimationStatus.dismissed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LeafVideoPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.url != widget.url || oldWidget.headers != widget.headers) {
|
||||
_loaded = false;
|
||||
_ready = false;
|
||||
_failed = false;
|
||||
setState(() {});
|
||||
_load();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_backgrounded = state == AppLifecycleState.paused || state == AppLifecycleState.hidden;
|
||||
_updateCovered();
|
||||
}
|
||||
|
||||
void _onRouteStatus(AnimationStatus status) {
|
||||
_setObscured(status != AnimationStatus.dismissed);
|
||||
}
|
||||
|
||||
void _setObscured(bool obscured) {
|
||||
if (_obscured == obscured) return;
|
||||
_obscured = obscured;
|
||||
_updateCovered();
|
||||
}
|
||||
|
||||
void _onIndexChanged() => _evaluateActive();
|
||||
|
||||
void _evaluateActive() {
|
||||
final index = _pageIndex;
|
||||
final active = index == null || (_currentIndex?.value == index);
|
||||
if (active == _active) return;
|
||||
_active = active;
|
||||
if (!active) {
|
||||
// Release hold for the page leaving the stage; the active page's policy
|
||||
// owns it from here.
|
||||
_chrome?.hold = false;
|
||||
_adapter.pause();
|
||||
} else if (_loaded && !_failed && !_obscured && !_backgrounded) {
|
||||
_adapter.play();
|
||||
}
|
||||
}
|
||||
|
||||
void _updateCovered() {
|
||||
final covered = _obscured || _backgrounded;
|
||||
if (covered) {
|
||||
if (_wasCovered) return;
|
||||
_wasPlayingBeforeCover = _adapter.isPlaying;
|
||||
_adapter.pause();
|
||||
} else {
|
||||
if (!_wasCovered) return;
|
||||
if (_wasPlayingBeforeCover && _active && _loaded && !_failed) _adapter.play();
|
||||
}
|
||||
_wasCovered = covered;
|
||||
}
|
||||
|
||||
void _onPlayingChanged(bool playing) {
|
||||
if (!_active) return;
|
||||
final chrome = _chrome;
|
||||
if (chrome == null) return;
|
||||
if (playing) {
|
||||
chrome.hold = false;
|
||||
} else {
|
||||
chrome.hold = true;
|
||||
chrome.show();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _retry() async {
|
||||
setState(() => _failed = false);
|
||||
await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final sub in _subs) {
|
||||
sub.cancel();
|
||||
}
|
||||
_currentIndex?.removeListener(_onIndexChanged);
|
||||
_routeAnimation?.removeStatusListener(_onRouteStatus);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_adapter.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = widget.aspectRatio;
|
||||
final surface = ratio != null ? Center(child: AspectRatio(aspectRatio: ratio, child: _adapter.surface)) : _adapter.surface;
|
||||
final thumbnail = widget.thumbnail;
|
||||
final controls =
|
||||
widget.controlsBuilder?.call(context, _adapter) ??
|
||||
LeafVideoControls(adapter: _adapter, skipDuration: widget.skipDuration, onFullscreenToggle: widget.onFullscreenToggle);
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
surface,
|
||||
if (!_ready && thumbnail != null)
|
||||
IgnorePointer(
|
||||
child: ratio != null ? Center(child: AspectRatio(aspectRatio: ratio, child: thumbnail)) : Center(child: thumbnail),
|
||||
),
|
||||
if (!_loaded && !_failed && !_ready && thumbnail == null)
|
||||
const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
Positioned.fill(child: controls),
|
||||
if (_failed)
|
||||
ColoredBox(
|
||||
color: Colors.black54,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.broken_image, color: Colors.white38, size: 40),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(onPressed: _retry, child: const Text('Tap to retry', style: TextStyle(color: Colors.white38))),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user