Files
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

301 lines
9.6 KiB
Dart

part of '../media_viewer_page.dart';
class _MediaViewerBottomControls extends StatelessWidget {
final List<MediaViewerImage> images;
final int currentIndex;
final ValueListenable<double> pagePosition;
final ValueChanged<double> onScrubUpdate;
final VoidCallback onScrubEnd;
final ValueChanged<int> onSelect;
final VoidCallback? onReply;
final VoidCallback? onMore;
const _MediaViewerBottomControls({
required this.images,
required this.currentIndex,
required this.pagePosition,
required this.onScrubUpdate,
required this.onScrubEnd,
required this.onSelect,
required this.onReply,
required this.onMore,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(Grid.sm, Grid.xxs, Grid.sm, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_MediaViewerCircleButton(
key: const ValueKey('message-media-image-viewer-reply-thread'),
icon: LucideIcons.messageSquareReply,
tooltip: context.l10n.replyInThread,
onPressed: onReply,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: images.length > 1
? _MediaViewerFilmstrip(
key: const ValueKey('message-media-image-viewer-filmstrip'),
images: images,
currentIndex: currentIndex,
pagePosition: pagePosition,
onScrubUpdate: onScrubUpdate,
onScrubEnd: onScrubEnd,
onSelect: onSelect,
)
: const SizedBox(height: 56),
),
const SizedBox(width: Grid.xxs),
_MediaViewerCircleButton(
key: const ValueKey('message-media-image-viewer-more-actions'),
icon: LucideIcons.ellipsis,
tooltip: context.l10n.moreImageActions,
onPressed: onMore,
),
],
),
);
}
}
class _MediaViewerFilmstrip extends StatelessWidget {
final List<MediaViewerImage> images;
final int currentIndex;
final ValueListenable<double> pagePosition;
final ValueChanged<double> onScrubUpdate;
final VoidCallback onScrubEnd;
final ValueChanged<int> onSelect;
const _MediaViewerFilmstrip({
super.key,
required this.images,
required this.currentIndex,
required this.pagePosition,
required this.onScrubUpdate,
required this.onScrubEnd,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 56,
child: RepaintBoundary(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (details) =>
onScrubUpdate(details.primaryDelta ?? 0),
onHorizontalDragEnd: (_) => onScrubEnd(),
child: ValueListenableBuilder<double>(
valueListenable: pagePosition,
builder: (context, position, _) {
return LayoutBuilder(
builder: (context, constraints) {
const compactWidth = 40.0;
const focusedWidth = 72.0;
const itemHeight = 52.0;
const spacing = Grid.half;
final clampedPosition = position
.clamp(0.0, images.length - 1.0)
.toDouble();
final widths = <double>[];
final proximities = <double>[];
final centers = <double>[];
var cursor = 0.0;
for (var index = 0; index < images.length; index++) {
final proximity = (1 - (index - clampedPosition).abs())
.clamp(0.0, 1.0)
.toDouble();
final width =
compactWidth +
((focusedWidth - compactWidth) * proximity);
widths.add(width);
proximities.add(proximity);
centers.add(cursor + (width / 2));
cursor += width + spacing;
}
final lowerIndex = clampedPosition.floor();
final upperIndex = clampedPosition.ceil();
final fraction = clampedPosition - lowerIndex;
final lowerCenter = centers[lowerIndex];
final upperCenter = centers[upperIndex];
final focusCenter =
lowerCenter + ((upperCenter - lowerCenter) * fraction);
final viewportCenter = constraints.maxWidth / 2;
return Stack(
clipBehavior: Clip.hardEdge,
children: [
for (var index = 0; index < images.length; index++)
Positioned(
left:
viewportCenter +
centers[index] -
focusCenter -
(widths[index] / 2),
top: Grid.quarter,
width: widths[index],
height: itemHeight,
child: _MediaViewerFilmstripImage(
image: images[index],
index: index,
proximity: proximities[index],
selected: index == currentIndex,
onSelect: onSelect,
),
),
],
);
},
);
},
),
),
),
);
}
}
class _MediaViewerFilmstripImage extends StatelessWidget {
final MediaViewerImage image;
final int index;
final double proximity;
final bool selected;
final ValueChanged<int> onSelect;
const _MediaViewerFilmstripImage({
required this.image,
required this.index,
required this.proximity,
required this.selected,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
final borderWidth = 1 + (1.5 * proximity);
return Semantics(
button: true,
selected: selected,
label: selected
? context.l10n.imageSelected(index + 1)
: context.l10n.showImage(index + 1),
child: GestureDetector(
key: ValueKey('message-media-image-viewer-thumbnail:$index'),
onTap: () => onSelect(index),
child: Container(
height: 52,
padding: EdgeInsets.all(borderWidth),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(
color: Colors.white.withValues(alpha: 0.28 + (0.72 * proximity)),
width: borderWidth,
),
),
child: ClipRRect(
key: ValueKey('message-media-image-viewer-thumbnail-clip:$index'),
borderRadius: BorderRadius.circular(Radii.sm - borderWidth),
clipBehavior: Clip.antiAlias,
child: Opacity(
opacity: 0.62 + (0.38 * proximity),
child: MediaImage(
url: image.url,
decodeWidth: 72,
fit: BoxFit.cover,
semanticLabel: image.semanticLabel,
errorBuilder: (_, _, _) => const ColoredBox(
color: Color.fromRGBO(255, 255, 255, 0.12),
child: Icon(
LucideIcons.imageOff,
color: Colors.white70,
size: 18,
),
),
),
),
),
),
),
);
}
}
class _MediaViewerCircleButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
const _MediaViewerCircleButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 48,
child: onPressed == null
? const SizedBox.shrink()
: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.16),
shape: BoxShape.circle,
),
child: IconButton(
onPressed: onPressed,
tooltip: tooltip,
icon: Icon(icon, color: Colors.white, size: 20),
),
),
);
}
}
Size _imageViewerSize(Size viewport, double? aspectRatio) {
if (aspectRatio == null || aspectRatio <= 0) {
return viewport;
}
final safeAspectRatio = aspectRatio.clamp(0.05, 20.0).toDouble();
if (viewport.aspectRatio > safeAspectRatio) {
return Size(viewport.height * safeAspectRatio, viewport.height);
}
return Size(viewport.width, viewport.width / safeAspectRatio);
}
void _precacheViewerImages(
BuildContext context,
List<MediaViewerImage> images,
int focusedIndex,
) {
for (var index = focusedIndex - 2; index <= focusedIndex + 2; index++) {
if (index < 0 || index >= images.length) {
continue;
}
final provider = images[index].preloadProvider;
if (provider == null) {
continue;
}
unawaited(precacheImage(provider, context, onError: (_, _) {}));
}
}
bool _hasImageTransform(Matrix4 transform) {
final storage = transform.storage;
for (var index = 0; index < storage.length; index++) {
if ((storage[index] - _identityTransformStorage[index]).abs() >
_identityTransformEpsilon) {
return true;
}
}
return false;
}