Files
buzz/mobile/lib/features/channels/media_viewer_page.dart
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

704 lines
24 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:path_provider/path_provider.dart';
import 'package:video_player/video_player.dart';
import '../../shared/relay/relay.dart';
import '../../shared/l10n/l10n.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import 'media_viewer_hero.dart';
export 'media_viewer_hero.dart';
part 'media_viewer_page/image_controls.dart';
part 'media_viewer_page/route_transition.dart';
part 'media_viewer_page/video_controls.dart';
part 'media_viewer_page/video_viewer.dart';
const _imageViewerPushDuration = Duration(milliseconds: 260);
const _imageViewerPopDuration = Duration(milliseconds: 170);
const _identityTransformEpsilon = 0.0001;
final List<double> _identityTransformStorage = List<double>.unmodifiable(
Matrix4.identity().storage,
);
/// Opens message-specific actions for the currently visible image.
typedef MediaViewerMoreAction =
void Function(BuildContext context, String imageUrl);
/// An image and its source Hero tag in a full-screen media gallery.
@immutable
class MediaViewerImage {
/// The image URL.
final String url;
/// The shared-element transition tag for the source thumbnail.
final Object heroTag;
/// The accessible image description.
final String? semanticLabel;
/// The logical decode width already cached by the source thumbnail.
final double? previewDecodeWidth;
/// The image's intrinsic width-to-height ratio, when provided by metadata.
final double? aspectRatio;
/// A display-sized provider that can be warmed before this page is shown.
final ImageProvider<Object>? preloadProvider;
/// Creates a media-viewer image.
const MediaViewerImage({
required this.url,
required this.heroTag,
this.semanticLabel,
this.previewDecodeWidth,
this.aspectRatio,
this.preloadProvider,
});
}
PageRoute<void> buildImageViewerRoute({
required String imageUrl,
required Object heroTag,
String? semanticLabel,
double? previewDecodeWidth,
double? aspectRatio,
List<MediaViewerImage>? galleryItems,
int initialIndex = 0,
VoidCallback? onReply,
MediaViewerMoreAction? onMore,
bool disableAnimations = false,
}) {
final images =
galleryItems ??
[
MediaViewerImage(
url: imageUrl,
heroTag: heroTag,
semanticLabel: semanticLabel,
previewDecodeWidth: previewDecodeWidth,
aspectRatio: aspectRatio,
),
];
final safeInitialIndex = initialIndex.clamp(0, images.length - 1).toInt();
return PageRouteBuilder<void>(
transitionDuration: disableAnimations
? Duration.zero
: _imageViewerPushDuration,
reverseTransitionDuration: disableAnimations
? Duration.zero
: _imageViewerPopDuration,
pageBuilder: (context, animation, secondaryAnimation) =>
MediaImageViewerPage(
imageUrl: imageUrl,
heroTag: heroTag,
semanticLabel: semanticLabel,
galleryItems: images,
initialIndex: safeInitialIndex,
onReply: onReply,
onMore: onMore,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
_MediaViewerRouteTransition(animation: animation, child: child),
);
}
void openImageViewer(
BuildContext context, {
required String imageUrl,
required Object heroTag,
String? semanticLabel,
double? previewDecodeWidth,
double? aspectRatio,
List<MediaViewerImage>? galleryItems,
int initialIndex = 0,
VoidCallback? onReply,
MediaViewerMoreAction? onMore,
}) {
Navigator.of(context).push(
buildImageViewerRoute(
imageUrl: imageUrl,
heroTag: heroTag,
semanticLabel: semanticLabel,
previewDecodeWidth: previewDecodeWidth,
aspectRatio: aspectRatio,
galleryItems: galleryItems,
initialIndex: initialIndex,
onReply: onReply,
onMore: onMore,
disableAnimations: MediaQuery.disableAnimationsOf(context),
),
);
}
void openVideoViewer(
BuildContext context, {
required String videoUrl,
String? posterUrl,
VoidCallback? onReply,
}) {
Navigator.of(context).push(
PageRouteBuilder<void>(
transitionDuration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: _imageViewerPushDuration,
reverseTransitionDuration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: _imageViewerPopDuration,
pageBuilder: (context, animation, secondaryAnimation) =>
MediaVideoViewerPage(
videoUrl: videoUrl,
posterUrl: posterUrl,
onReply: onReply,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
_MediaViewerRouteTransition(animation: animation, child: child),
),
);
}
class MediaImageViewerPage extends HookConsumerWidget {
final String imageUrl;
final Object heroTag;
final String? semanticLabel;
final List<MediaViewerImage>? galleryItems;
final int initialIndex;
final VoidCallback? onReply;
final MediaViewerMoreAction? onMore;
const MediaImageViewerPage({
super.key,
required this.imageUrl,
required this.heroTag,
this.semanticLabel,
this.galleryItems,
this.initialIndex = 0,
this.onReply,
this.onMore,
});
static const _dismissThreshold = 100.0;
static const _dismissVelocity = 700.0;
static const _backgroundFadeDivisor = 300.0;
static const _filmstripScrubExtent = 44.0;
@override
Widget build(BuildContext context, WidgetRef ref) {
final images = useMemoized(
() =>
galleryItems ??
[
MediaViewerImage(
url: imageUrl,
heroTag: heroTag,
semanticLabel: semanticLabel,
),
],
[galleryItems, imageUrl, heroTag, semanticLabel],
);
final safeInitialIndex = initialIndex.clamp(0, images.length - 1).toInt();
final currentIndex = useState(safeInitialIndex);
final pageController = usePageController(initialPage: safeInitialIndex);
final pagePosition = useState(safeInitialIndex.toDouble());
final transformationControllers = useMemoized(
() => [
for (var index = 0; index < images.length; index++)
TransformationController(),
],
[images],
);
final snapBackController = useAnimationController(
duration: const Duration(milliseconds: 200),
);
final zoomResetController = useAnimationController(
duration: const Duration(milliseconds: 180),
);
final zoomResetListener = useRef<VoidCallback?>(null);
final fullResolutionIndices = useState<Set<int>>(<int>{});
final isTransformed = useState(false);
final disableHeroOnDismiss = useState(false);
final dragOffset = useState(0.0);
final isDragging = useState(false);
void handlePagePositionChanged() {
if (!pageController.hasClients) return;
final nextPosition = pageController.page;
if (nextPosition == null ||
(nextPosition - pagePosition.value).abs() < 0.0001) {
return;
}
pagePosition.value = nextPosition;
}
void handleTransformChanged(int index) {
if (index != currentIndex.value) return;
final nextIsTransformed = _hasImageTransform(
transformationControllers[index].value,
);
if (nextIsTransformed == isTransformed.value) {
return;
}
isTransformed.value = nextIsTransformed;
if (nextIsTransformed && isDragging.value) {
isDragging.value = false;
dragOffset.value = 0;
}
}
useEffect(() {
pageController.addListener(handlePagePositionChanged);
return () => pageController.removeListener(handlePagePositionChanged);
}, [pageController]);
useEffect(() {
final listeners = <VoidCallback>[];
for (var index = 0; index < transformationControllers.length; index++) {
final controllerIndex = index;
void listener() => handleTransformChanged(controllerIndex);
listeners.add(listener);
transformationControllers[index].addListener(listener);
}
return () {
for (var index = 0; index < transformationControllers.length; index++) {
transformationControllers[index]
..removeListener(listeners[index])
..dispose();
}
};
}, [transformationControllers]);
useEffect(() {
var cancelled = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!cancelled && context.mounted) {
_precacheViewerImages(context, images, currentIndex.value);
}
});
return () => cancelled = true;
}, [images]);
useEffect(() {
return () {
final listener = zoomResetListener.value;
if (listener != null) {
zoomResetController.removeListener(listener);
}
};
}, [zoomResetController]);
void onPageChanged(int index) {
_precacheViewerImages(context, images, index);
currentIndex.value = index;
isTransformed.value = _hasImageTransform(
transformationControllers[index].value,
);
disableHeroOnDismiss.value = index != safeInitialIndex;
dragOffset.value = 0;
isDragging.value = false;
}
void onFilmstripScrubUpdate(double delta) {
if (isTransformed.value || !pageController.hasClients) return;
final position = pageController.position;
final viewport = position.viewportDimension;
if (viewport <= 0) return;
final target =
(pageController.offset - ((delta / _filmstripScrubExtent) * viewport))
.clamp(position.minScrollExtent, position.maxScrollExtent)
.toDouble();
pageController.jumpTo(target);
}
void onFilmstripScrubEnd() {
if (!pageController.hasClients) return;
final targetPage = (pageController.page ?? currentIndex.value.toDouble())
.round()
.clamp(0, images.length - 1);
if (MediaQuery.disableAnimationsOf(context)) {
pageController.jumpToPage(targetPage);
return;
}
pageController.animateToPage(
targetPage,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
);
}
void upgradeToFullResolution(int index) {
if (images[index].previewDecodeWidth == null ||
fullResolutionIndices.value.contains(index)) {
return;
}
fullResolutionIndices.value = {...fullResolutionIndices.value, index};
}
void onImageInteractionStart(int index, ScaleStartDetails details) {
if (details.pointerCount > 1) {
upgradeToFullResolution(index);
}
if (details.pointerCount == 1 && !isTransformed.value) {
isDragging.value = true;
}
}
void onImageInteractionUpdate(int index, ScaleUpdateDetails details) {
if (details.pointerCount > 1 || details.scale != 1) {
final needsFullResolution =
images[index].previewDecodeWidth != null &&
!fullResolutionIndices.value.contains(index);
if (isDragging.value || needsFullResolution) {
if (needsFullResolution) {
fullResolutionIndices.value = {
...fullResolutionIndices.value,
index,
};
}
isDragging.value = false;
dragOffset.value = 0;
}
return;
}
if (!isDragging.value || isTransformed.value) return;
dragOffset.value = (dragOffset.value + details.focalPointDelta.dy).clamp(
0.0,
MediaQuery.sizeOf(context).height,
);
}
void resetImageTransform(int index) {
final controller = transformationControllers[index];
if (!_hasImageTransform(controller.value)) {
return;
}
final previousListener = zoomResetListener.value;
if (previousListener != null) {
zoomResetController.removeListener(previousListener);
}
zoomResetController.stop();
if (MediaQuery.disableAnimationsOf(context)) {
controller.value = Matrix4.identity();
zoomResetListener.value = null;
return;
}
final animation =
Matrix4Tween(
begin: Matrix4.copy(controller.value),
end: Matrix4.identity(),
).animate(
CurvedAnimation(
parent: zoomResetController,
curve: Curves.easeOutCubic,
),
);
void listener() => controller.value = animation.value;
zoomResetListener.value = listener;
zoomResetController
..reset()
..addListener(listener)
..forward();
}
bool canDismissWithHero() =>
!isTransformed.value || disableHeroOnDismiss.value;
Future<void> prepareHeroFallbackDismiss() async {
if (canDismissWithHero()) {
return;
}
disableHeroOnDismiss.value = true;
await WidgetsBinding.instance.endOfFrame;
}
Future<void> dismiss() async {
await prepareHeroFallbackDismiss();
if (!context.mounted) {
return;
}
Navigator.of(context).maybePop();
}
void animateSnapBack() {
final tween = Tween<double>(begin: dragOffset.value, end: 0);
void listener() {
dragOffset.value = tween.evaluate(snapBackController);
}
snapBackController
..stop()
..reset()
..addListener(listener);
snapBackController
.animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 260),
bounce: 0.14,
),
0,
1,
0,
snapToEnd: true,
),
)
.whenCompleteOrCancel(() {
snapBackController.removeListener(listener);
});
}
void finishVerticalDismiss(double velocity) {
isDragging.value = false;
if (dragOffset.value > _dismissThreshold || velocity > _dismissVelocity) {
unawaited(dismiss());
} else {
animateSnapBack();
}
}
void onImageInteractionEnd(ScaleEndDetails details) {
if (!isDragging.value) return;
finishVerticalDismiss(details.velocity.pixelsPerSecond.dy);
}
Future<void> replyInThread() async {
final callback = onReply;
if (callback == null) return;
final route = ModalRoute.of(context);
await dismiss();
await route?.completed;
callback();
}
void showMoreActions() {
onMore?.call(context, images[currentIndex.value].url);
}
final viewportHeight = MediaQuery.sizeOf(context).height;
final dragProgress = (dragOffset.value / viewportHeight).clamp(0.0, 1.0);
final imageScale = 1 - (dragProgress * 0.1);
final chromeOpacity = (1 - (dragOffset.value / 160)).clamp(0.0, 1.0);
return PopScope<void>(
canPop: canDismissWithHero(),
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
return;
}
unawaited(dismiss());
},
child: Scaffold(
key: const ValueKey('message-media-image-viewer'),
backgroundColor: Colors.black.withValues(
alpha: (1 - (dragOffset.value.abs() / _backgroundFadeDivisor)).clamp(
0.3,
1.0,
),
),
body: Stack(
children: [
Positioned.fill(
child: Transform.translate(
offset: Offset(0, dragOffset.value),
child: Transform.scale(
scale: imageScale,
child: PageView.builder(
key: const ValueKey('message-media-image-viewer-pages'),
controller: pageController,
physics: isTransformed.value
? const NeverScrollableScrollPhysics()
: const PageScrollPhysics(),
itemCount: images.length,
onPageChanged: onPageChanged,
itemBuilder: (context, index) {
final image = images[index];
final viewPadding = MediaQuery.viewPaddingOf(context);
return Padding(
padding: EdgeInsets.only(
top: viewPadding.top + 48 + Grid.xxs,
bottom: viewPadding.bottom + 56 + (Grid.xxs * 2),
),
child: LayoutBuilder(
builder: (context, constraints) {
final viewerSize = _imageViewerSize(
Size(constraints.maxWidth, constraints.maxHeight),
image.aspectRatio,
);
return GestureDetector(
key: ValueKey(
'message-media-image-viewer-gesture:$index',
),
behavior: HitTestBehavior.opaque,
onDoubleTap: () => resetImageTransform(index),
child: InteractiveViewer(
transformationController:
transformationControllers[index],
onInteractionStart: (details) =>
onImageInteractionStart(index, details),
onInteractionUpdate: (details) =>
onImageInteractionUpdate(index, details),
onInteractionEnd: onImageInteractionEnd,
panEnabled: isTransformed.value,
scaleEnabled: true,
minScale: 1,
maxScale: 4,
boundaryMargin: const EdgeInsets.all(Grid.xxl),
clipBehavior: Clip.none,
child: Align(
alignment: Alignment.center,
child: SizedBox(
width: viewerSize.width,
height: viewerSize.height,
child: HeroMode(
key: index == safeInitialIndex
? const ValueKey(
'message-media-image-viewer-hero-mode',
)
: ValueKey(
'message-media-image-viewer-hero-mode-$index',
),
enabled:
!disableHeroOnDismiss.value &&
index == safeInitialIndex,
child: MediaViewerHero(
tag: image.heroTag,
child: MediaImage(
key: ValueKey(
'message-media-image-viewer-image:$index',
),
url: image.url,
decodeWidth:
fullResolutionIndices.value
.contains(index)
? null
: image.previewDecodeWidth,
boundDecodeToLayout: false,
fit: BoxFit.contain,
semanticLabel: image.semanticLabel,
errorBuilder: (_, _, _) =>
_MediaLoadFailure(
message:
context.l10n.failedLoadImage,
icon: LucideIcons.imageOff,
),
),
),
),
),
),
),
);
},
),
);
},
),
),
),
),
PositionedDirectional(
bottom: 0,
start: 0,
end: 0,
child: Opacity(
opacity: chromeOpacity,
child: SafeArea(
child: _MediaViewerBottomControls(
images: images,
currentIndex: currentIndex.value,
pagePosition: pagePosition,
onScrubUpdate: onFilmstripScrubUpdate,
onScrubEnd: onFilmstripScrubEnd,
onSelect: (index) {
if (index == currentIndex.value) return;
if (MediaQuery.disableAnimationsOf(context)) {
pageController.jumpToPage(index);
return;
}
pageController.animateToPage(
index,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
},
onReply: onReply == null
? null
: () => unawaited(replyInThread()),
onMore: onMore == null ? null : showMoreActions,
),
),
),
),
PositionedDirectional(
top: 0,
end: Grid.sm,
child: Opacity(
opacity: chromeOpacity,
child: SafeArea(
child: _MediaViewerCircleButton(
key: const ValueKey('message-media-image-viewer-close'),
icon: LucideIcons.x,
tooltip: context.l10n.closeImageViewer,
onPressed: () => unawaited(dismiss()),
),
),
),
),
],
),
),
);
}
}
class _MediaLoadFailure extends StatelessWidget {
final String message;
final IconData icon;
const _MediaLoadFailure({required this.message, required this.icon});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final showMessage = constraints.maxWidth >= 120;
final iconSize = constraints.biggest.shortestSide
.clamp(0.0, 36.0)
.toDouble();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white70, size: iconSize),
if (showMessage) ...[
const SizedBox(height: Grid.xxs),
Text(
message,
style: context.textTheme.bodyMedium?.copyWith(
color: Colors.white70,
),
textAlign: TextAlign.center,
),
],
],
);
},
);
}
}