video: desktop controls, shared scrubber, lifecycle guard and first-frame gate; zoomable and adapter tweaks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9b1c8fa368
commit
c0ab4a828b
12 files changed
+936
-9
No files matched your search
@@ -7,8 +7,12 @@ 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_desktop_video_controls.dart';
|
||||
export 'src/video/first_frame_gate.dart';
|
||||
export 'src/video/leaf_scrubber.dart';
|
||||
export 'src/video/leaf_video_adapter.dart';
|
||||
export 'src/video/leaf_video_controls.dart';
|
||||
export 'src/video/video_lifecycle.dart';
|
||||
export 'src/video/leaf_video_page.dart';
|
||||
export 'src/viewer/leaf_chrome.dart';
|
||||
export 'src/viewer/leaf_viewer.dart';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
/// Caps concurrent first-frame decodes across feed tiles.
|
||||
///
|
||||
/// The expensive moment is a video's first decode + first texture present.
|
||||
/// Preloading N tiles at build bursts N of those at once and drops scroll
|
||||
/// frames. Tiles `await` [enter] before opening media; the gate lets
|
||||
/// [maxConcurrent] through and queues the rest, releasing the next waiter on
|
||||
/// [exit] (first frame shown, playback started, or tile disposed).
|
||||
/// Player-agnostic: works for media_kit and native players alike.
|
||||
class FirstFrameGate {
|
||||
/// Maximum simultaneous first-frame decodes. Two suits midrange SoCs.
|
||||
FirstFrameGate({this.maxConcurrent = 2}) : assert(maxConcurrent > 0);
|
||||
|
||||
final int maxConcurrent;
|
||||
|
||||
int _active = 0;
|
||||
final _waiters = Queue<Completer<void>>();
|
||||
|
||||
/// Waits for a decode slot. Completes immediately when one is free.
|
||||
Future<void> enter() {
|
||||
if (_active < maxConcurrent) {
|
||||
_active++;
|
||||
return Future.value();
|
||||
}
|
||||
final waiter = Completer<void>();
|
||||
_waiters.add(waiter);
|
||||
return waiter.future;
|
||||
}
|
||||
|
||||
/// Frees this holder's slot, handing it to the longest waiter if any.
|
||||
/// Exiting without entering only clamps; it never goes negative.
|
||||
void exit() {
|
||||
if (_waiters.isNotEmpty) {
|
||||
_waiters.removeFirst().complete();
|
||||
return;
|
||||
}
|
||||
if (_active > 0) _active--;
|
||||
}
|
||||
|
||||
/// Current holders, for diagnostics.
|
||||
int get activeCount => _active;
|
||||
|
||||
/// Queued waiters, for diagnostics.
|
||||
int get waitingCount => _waiters.length;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'leaf_scrubber.dart';
|
||||
import 'leaf_video_adapter.dart';
|
||||
|
||||
/// Desktop control set for [LeafVideoPage]: single click anywhere toggles
|
||||
/// playback, double-click goes fullscreen, the bar hides itself after
|
||||
/// [hideDelay] of inactivity (and while the cursor is off the video),
|
||||
/// the scrubber shows a hover time bubble before anything is clicked, and
|
||||
/// volume appears only when the adapter implements [HasVolume].
|
||||
///
|
||||
/// Visibility is owned here, not by any viewer chrome.
|
||||
class LeafDesktopVideoControls extends StatefulWidget {
|
||||
const LeafDesktopVideoControls({super.key, required this.adapter, this.onFullscreenToggle, this.hideDelay = const Duration(seconds: 3)});
|
||||
|
||||
final VideoPlayerAdapter adapter;
|
||||
|
||||
/// Null hides the fullscreen button and makes double-click a no-op.
|
||||
final VoidCallback? onFullscreenToggle;
|
||||
|
||||
/// Idle time while playing after which the bar (and cursor) hide.
|
||||
final Duration hideDelay;
|
||||
|
||||
@override
|
||||
State<LeafDesktopVideoControls> createState() => _LeafDesktopVideoControlsState();
|
||||
}
|
||||
|
||||
class _LeafDesktopVideoControlsState extends State<LeafDesktopVideoControls> {
|
||||
bool _visible = true;
|
||||
Timer? _hideTimer;
|
||||
StreamSubscription<bool>? _playingSub;
|
||||
double _mutedVolume = 100;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_playingSub = widget.adapter.playingStream.listen(_onPlayingChanged);
|
||||
if (widget.adapter.isPlaying) _scheduleHide();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hideTimer?.cancel();
|
||||
_playingSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onPlayingChanged(bool playing) {
|
||||
if (playing) {
|
||||
_scheduleHide();
|
||||
} else {
|
||||
_show();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleHide() {
|
||||
_hideTimer?.cancel();
|
||||
_hideTimer = Timer(widget.hideDelay, () {
|
||||
if (!mounted || !widget.adapter.isPlaying) return;
|
||||
setState(() => _visible = false);
|
||||
});
|
||||
}
|
||||
|
||||
void _show() {
|
||||
_hideTimer?.cancel();
|
||||
if (mounted && !_visible) setState(() => _visible = true);
|
||||
}
|
||||
|
||||
void _userActivity() {
|
||||
_show();
|
||||
if (widget.adapter.isPlaying) _scheduleHide();
|
||||
}
|
||||
|
||||
void _hideForExit() {
|
||||
_hideTimer?.cancel();
|
||||
if (mounted && _visible && widget.adapter.isPlaying) setState(() => _visible = false);
|
||||
}
|
||||
|
||||
void _toggleFullscreen() => widget.onFullscreenToggle?.call();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: MouseRegion(
|
||||
cursor: _visible ? SystemMouseCursors.basic : SystemMouseCursors.none,
|
||||
onEnter: (_) => _userActivity(),
|
||||
onHover: (_) => _userActivity(),
|
||||
onExit: (_) => _hideForExit(),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
// onTapDown, not onTap: with onDoubleTap set, onTap would wait
|
||||
// out the double-tap timeout on every click. A double-click
|
||||
// then toggles twice (net zero) plus fullscreen.
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => widget.adapter.playOrPause(),
|
||||
onDoubleTap: _toggleFullscreen,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SafeArea(
|
||||
child: IgnorePointer(
|
||||
ignoring: !_visible,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _visible ? 1.0 : 0.0,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {},
|
||||
child: _buildBar(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBar(BuildContext context) {
|
||||
final volume = widget.adapter is HasVolume ? widget.adapter as HasVolume : null;
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(colors: [Colors.black, Colors.transparent], begin: Alignment.bottomCenter, end: Alignment.topCenter),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(10, 2, 10, 10),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
LeafScrubber(widget.adapter, knob: true, hoverPreview: true),
|
||||
Row(
|
||||
children: [
|
||||
StreamBuilder<bool>(
|
||||
stream: widget.adapter.playingStream,
|
||||
initialData: widget.adapter.isPlaying,
|
||||
builder: (context, snapshot) => IconButton(
|
||||
icon: Icon((snapshot.data ?? false) ? Icons.pause : Icons.play_arrow),
|
||||
onPressed: widget.adapter.playOrPause,
|
||||
),
|
||||
),
|
||||
if (volume != null) ...[
|
||||
StreamBuilder<double>(
|
||||
stream: volume.volumeStream,
|
||||
initialData: volume.volume,
|
||||
builder: (context, snapshot) {
|
||||
final level = snapshot.data ?? 0;
|
||||
return IconButton(
|
||||
icon: Icon(level <= 0 ? Icons.volume_off : (level < 50 ? Icons.volume_down : Icons.volume_up)),
|
||||
onPressed: () {
|
||||
if (volume.volume > 0) {
|
||||
_mutedVolume = volume.volume;
|
||||
volume.setVolume(0);
|
||||
} else {
|
||||
volume.setVolume(_mutedVolume);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: StreamBuilder<double>(
|
||||
stream: volume.volumeStream,
|
||||
initialData: volume.volume,
|
||||
builder: (context, snapshot) => Slider(
|
||||
value: (snapshot.data ?? 0).clamp(0.0, 100.0),
|
||||
min: 0,
|
||||
max: 100,
|
||||
onChanged: volume.setVolume,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
StreamBuilder<Duration>(
|
||||
stream: widget.adapter.positionStream,
|
||||
builder: (context, _) => Text(
|
||||
'${_formatDuration(widget.adapter.position)} / ${_formatDuration(widget.adapter.duration)}',
|
||||
style: const TextStyle(fontSize: 12, fontFeatures: [FontFeature.tabularFigures()]),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (widget.onFullscreenToggle != null) IconButton(icon: const Icon(Icons.fullscreen), onPressed: widget.onFullscreenToggle),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,179 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'leaf_video_adapter.dart';
|
||||
|
||||
/// Shared scrubber: buffered + position tracks, tap-to-seek, drag-to-scrub
|
||||
/// (pauses during the drag, resumes after when it was playing).
|
||||
///
|
||||
/// With [knob] the position gets an always-visible handle (desktop
|
||||
/// affordance). With [hoverPreview] a time bubble follows the cursor before
|
||||
/// anything is clicked.
|
||||
class LeafScrubber extends StatefulWidget {
|
||||
const LeafScrubber(this.adapter, {super.key, this.knob = false, this.hoverPreview = false});
|
||||
|
||||
final VideoPlayerAdapter adapter;
|
||||
|
||||
/// Always-visible position handle (desktop affordance).
|
||||
final bool knob;
|
||||
|
||||
/// Time bubble following the cursor before anything is clicked (desktop).
|
||||
final bool hoverPreview;
|
||||
|
||||
@override
|
||||
State<LeafScrubber> createState() => _LeafScrubberState();
|
||||
}
|
||||
|
||||
class _LeafScrubberState extends State<LeafScrubber> {
|
||||
bool _wasPaused = false;
|
||||
bool _dragging = false;
|
||||
double? _hoverFraction;
|
||||
|
||||
double _fraction(Duration value, double totalMs) {
|
||||
return totalMs > 0 ? (value.inMilliseconds / totalMs).clamp(0.0, 1.0) : 0.0;
|
||||
}
|
||||
|
||||
void _seekFraction(double fraction) {
|
||||
final totalMs = widget.adapter.duration.inMilliseconds;
|
||||
widget.adapter.seek(Duration(milliseconds: (totalMs * fraction.clamp(0.0, 1.0)).round()));
|
||||
}
|
||||
|
||||
double _dxToFraction(Offset localPosition, double width) {
|
||||
return width > 0 ? (localPosition.dx / width).clamp(0.0, 1.0) : 0.0;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final trackHeight = (_dragging || _hoverFraction != null) ? 6.0 : 4.0;
|
||||
const radius = Radius.circular(3);
|
||||
|
||||
Widget track = LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final totalMs = widget.adapter.duration.inMilliseconds.toDouble();
|
||||
final hover = _hoverFraction;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 22,
|
||||
width: width,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
if (widget.hoverPreview && hover != null && !_dragging)
|
||||
Positioned(
|
||||
left: (hover * width).clamp(24.0, width - 24.0) - 24.0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 48,
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
decoration: BoxDecoration(color: Colors.black54, borderRadius: BorderRadius.circular(4)),
|
||||
child: Text(
|
||||
_formatDuration(Duration(milliseconds: (totalMs * hover).round())),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 11, fontFeatures: [FontFeature.tabularFigures()]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: trackHeight,
|
||||
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, totalMs) * width).clamp(0.0, width),
|
||||
color: Colors.white30,
|
||||
),
|
||||
),
|
||||
StreamBuilder<Duration>(
|
||||
stream: widget.adapter.positionStream,
|
||||
builder: (context, _) {
|
||||
final positionWidth = (_fraction(widget.adapter.position, totalMs) * width).clamp(0.0, width);
|
||||
return Stack(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.fastEaseInToSlowEaseOut,
|
||||
width: positionWidth,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
if (widget.knob)
|
||||
Positioned(
|
||||
left: (positionWidth - 6).clamp(0.0, width - 12),
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(color: Colors.white, shape: BoxShape.circle),
|
||||
child: SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
track = GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (details) => _seekFraction(_dxToFraction(details.localPosition, (context.findRenderObject() as RenderBox).size.width)),
|
||||
onHorizontalDragStart: (_) {
|
||||
_wasPaused = !widget.adapter.isPlaying;
|
||||
widget.adapter.pause();
|
||||
setState(() => _dragging = true);
|
||||
},
|
||||
onHorizontalDragUpdate: (details) =>
|
||||
_seekFraction(_dxToFraction(details.localPosition, (context.findRenderObject() as RenderBox).size.width)),
|
||||
onHorizontalDragEnd: (_) {
|
||||
setState(() => _dragging = false);
|
||||
if (!_wasPaused) widget.adapter.play();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: track,
|
||||
),
|
||||
);
|
||||
|
||||
if (!widget.hoverPreview) return track;
|
||||
return MouseRegion(
|
||||
onHover: (event) {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return;
|
||||
setState(() => _hoverFraction = _dxToFraction(event.localPosition, box.size.width));
|
||||
},
|
||||
onExit: (_) {
|
||||
if (mounted) setState(() => _hoverFraction = null);
|
||||
},
|
||||
child: track,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
@@ -37,3 +37,12 @@ abstract class VideoPlayerAdapter {
|
||||
/// Releases the underlying player. The adapter is unusable afterwards.
|
||||
void dispose();
|
||||
}
|
||||
|
||||
/// Optional volume control. Adapters that can report and set output volume
|
||||
/// implement this; controls show the volume UI only when present, so players
|
||||
/// without volume support (or platforms where the system owns it) stay clean.
|
||||
abstract mixin class HasVolume {
|
||||
Stream<double> get volumeStream;
|
||||
double get volume;
|
||||
void setVolume(double volume);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||
import '../viewer/leaf_chrome.dart';
|
||||
import '../viewer/leaf_viewer.dart';
|
||||
import '../viewer/leaf_viewer_internal.dart';
|
||||
import 'leaf_desktop_video_controls.dart';
|
||||
import 'leaf_video_adapter.dart';
|
||||
import 'leaf_video_controls.dart';
|
||||
|
||||
@@ -49,7 +50,8 @@ class LeafVideoPage extends StatefulWidget {
|
||||
/// Shown until [VideoPlayerAdapter.readyStream] fires.
|
||||
final Widget? thumbnail;
|
||||
|
||||
/// Defaults to [LeafVideoControls].
|
||||
/// Defaults to [LeafVideoControls] on mobile, [LeafDesktopVideoControls]
|
||||
/// on desktop.
|
||||
final Widget Function(BuildContext context, VideoPlayerAdapter adapter)? controlsBuilder;
|
||||
final Duration skipDuration;
|
||||
|
||||
@@ -221,7 +223,9 @@ class _LeafVideoPageState extends State<LeafVideoPage> with WidgetsBindingObserv
|
||||
final thumbnail = widget.thumbnail;
|
||||
final controls =
|
||||
widget.controlsBuilder?.call(context, _adapter) ??
|
||||
LeafVideoControls(adapter: _adapter, skipDuration: widget.skipDuration, onFullscreenToggle: widget.onFullscreenToggle);
|
||||
(leafIsDesktopPlatform()
|
||||
? LeafDesktopVideoControls(adapter: _adapter, onFullscreenToggle: widget.onFullscreenToggle)
|
||||
: LeafVideoControls(adapter: _adapter, skipDuration: widget.skipDuration, onFullscreenToggle: widget.onFullscreenToggle));
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Pauses a playing child when the app backgrounds or the route is covered
|
||||
/// (pushed route, dialog, sheet, menu), and asks to resume when it returns.
|
||||
///
|
||||
/// Player-agnostic: drives plain callbacks, so custom viewers that don't use
|
||||
/// [LeafVideoPage] can share the policy. Cover detection uses
|
||||
/// [ModalRoute.isCurrent] (no [RouteObserver] setup needed); background uses
|
||||
/// [AppLifecycleState.paused] and [AppLifecycleState.hidden].
|
||||
/// Callbacks fire post-frame, so they may call `setState` on ancestors.
|
||||
class VideoLifecycleGuard extends StatefulWidget {
|
||||
const VideoLifecycleGuard({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.isPlaying,
|
||||
this.onPause,
|
||||
this.onResume,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// Current playing state. The guard only pauses (and later resumes) while
|
||||
/// this is true; an idle child gets no calls.
|
||||
final bool isPlaying;
|
||||
|
||||
/// Called once when coverage/background starts while [isPlaying].
|
||||
final VoidCallback? onPause;
|
||||
|
||||
/// Called once when fully uncovered/returned, and only if [onPause] fired.
|
||||
/// May be left null for pause-only consumers (play stays a tap away).
|
||||
final VoidCallback? onResume;
|
||||
|
||||
@override
|
||||
State<VideoLifecycleGuard> createState() => _VideoLifecycleGuardState();
|
||||
}
|
||||
|
||||
class _VideoLifecycleGuardState extends State<VideoLifecycleGuard> with WidgetsBindingObserver {
|
||||
bool _covered = false;
|
||||
bool _backgrounded = false;
|
||||
bool _holding = false;
|
||||
bool _wasPlaying = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final route = ModalRoute.of(context);
|
||||
_setCovered(route != null && !route.isCurrent);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_backgrounded = state == AppLifecycleState.paused || state == AppLifecycleState.hidden;
|
||||
_evaluate();
|
||||
}
|
||||
|
||||
void _setCovered(bool covered) {
|
||||
if (_covered == covered) return;
|
||||
_covered = covered;
|
||||
_evaluate();
|
||||
}
|
||||
|
||||
void _evaluate() {
|
||||
final held = _covered || _backgrounded;
|
||||
if (held == _holding) return;
|
||||
_holding = held;
|
||||
if (held) {
|
||||
if (widget.isPlaying) {
|
||||
_wasPlaying = true;
|
||||
_deliver(widget.onPause);
|
||||
}
|
||||
} else if (_wasPlaying) {
|
||||
_wasPlaying = false;
|
||||
_deliver(widget.onResume);
|
||||
}
|
||||
}
|
||||
|
||||
void _deliver(VoidCallback? callback) {
|
||||
if (callback == null) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
@@ -31,6 +31,8 @@ class LeafZoomable extends StatefulWidget {
|
||||
this.originalSize,
|
||||
this.maxScale = 5.0,
|
||||
this.enabled = true,
|
||||
this.onTap,
|
||||
this.onZoomChanged,
|
||||
}) : image = null;
|
||||
|
||||
const LeafZoomable.image({
|
||||
@@ -38,6 +40,8 @@ class LeafZoomable extends StatefulWidget {
|
||||
required Image image,
|
||||
this.maxScale = 5.0,
|
||||
this.enabled = true,
|
||||
this.onTap,
|
||||
this.onZoomChanged,
|
||||
}) : child = image,
|
||||
childSize = null,
|
||||
originalSize = null,
|
||||
@@ -53,6 +57,8 @@ class LeafZoomable extends StatefulWidget {
|
||||
|
||||
final double maxScale;
|
||||
final bool enabled;
|
||||
final VoidCallback? onTap;
|
||||
final ValueChanged<bool>? onZoomChanged;
|
||||
|
||||
/// The contained (letterboxed) size of [source] inside [viewport].
|
||||
static Size containSize(Size viewport, Size source) {
|
||||
@@ -78,12 +84,22 @@ class _LeafZoomableState extends State<LeafZoomable> with TickerProviderStateMix
|
||||
ImageStreamListener? _imageListener;
|
||||
Size? _decodedSize;
|
||||
bool _imageResolved = false;
|
||||
bool _lastZoomed = false;
|
||||
|
||||
void _notifyZoom() {
|
||||
final zoomed = _controller.zoomed;
|
||||
if (zoomed == _lastZoomed) return;
|
||||
_lastZoomed = zoomed;
|
||||
widget.onZoomChanged?.call(zoomed);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = LeafZoomController(maxScale: widget.maxScale, vsync: this)
|
||||
..onZoomLimit = () => HapticFeedback.mediumImpact();
|
||||
_lastZoomed = _controller.zoomed;
|
||||
_controller.addListener(_notifyZoom);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -150,6 +166,7 @@ class _LeafZoomableState extends State<LeafZoomable> with TickerProviderStateMix
|
||||
_imageStream!.removeListener(_imageListener!);
|
||||
}
|
||||
_unregister();
|
||||
_controller.removeListener(_notifyZoom);
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -221,21 +238,18 @@ class _LeafZoomableState extends State<LeafZoomable> with TickerProviderStateMix
|
||||
..onEnd = _onScaleEnd,
|
||||
),
|
||||
};
|
||||
if (LeafViewerScope.maybeOf(context)?.registry.swipeGesturesEnabled ?? false) {
|
||||
if (LeafViewerScope.maybeOf(context)?.registry.swipeGesturesEnabled ?? true) {
|
||||
gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
||||
() => DoubleTapGestureRecognizer(debugOwner: this),
|
||||
(instance) => instance.onDoubleTapDown = (details) => _controller.doubleTap(details.localPosition),
|
||||
);
|
||||
}
|
||||
// Chrome-enabled viewers: single tap toggles chrome. Registered on
|
||||
// THIS detector so the tap/double-tap arena coordination holds —
|
||||
// a cross-detector tap would fire on the first tap of every
|
||||
// double-tap-zoom.
|
||||
final chrome = LeafViewerScope.maybeOf(context)?.chrome;
|
||||
if (chrome != null) {
|
||||
final tapCallback = widget.onTap ?? chrome?.toggle;
|
||||
if (tapCallback != null) {
|
||||
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(debugOwner: this),
|
||||
(instance) => instance.onTap = chrome.toggle,
|
||||
(instance) => instance.onTap = tapCallback,
|
||||
);
|
||||
}
|
||||
return Listener(
|
||||
|
||||
Reference in new issue
Block a user