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

229 lines
7.2 KiB
Dart

part of '../message_content.dart';
/// A decoded, paused video surface used as a posterless message preview.
class LoadedVideoPreviewFrame {
/// Widget backed by the initialized native video texture.
final Widget child;
/// Aspect ratio reported by the video decoder.
final double aspectRatio;
final Future<void> Function() _dispose;
/// Creates a loaded preview frame with its resource cleanup callback.
const LoadedVideoPreviewFrame({
required this.child,
required this.aspectRatio,
required Future<void> Function() dispose,
}) : _dispose = dispose;
/// Releases the native decoder and any downloaded temporary file.
Future<void> dispose() => _dispose();
}
/// Loads a paused first-frame surface for a video URL.
typedef VideoPreviewFrameLoader =
Future<LoadedVideoPreviewFrame?> Function(String url);
/// Loader used when an older video event has no NIP-71 poster URL.
final videoPreviewFrameLoaderProvider = Provider<VideoPreviewFrameLoader>((
ref,
) {
final auth = ref.watch(mediaGetAuthServiceProvider);
return (url) => _loadVideoPreviewFrame(url, headers: auth.headersFor(url));
});
class _MessageVideoPreview extends HookConsumerWidget {
final String url;
final ImetaEntry? imeta;
final VoidCallback? onReply;
const _MessageVideoPreview({
required this.url,
required this.imeta,
required this.onReply,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final rawAspectRatio = imeta?.aspectRatio ?? (16 / 9);
final aspectRatio = rawAspectRatio.clamp(0.75, 1.91);
final posterUrl = imeta?.posterUrl;
final loadPreviewFrame = ref.watch(videoPreviewFrameLoaderProvider);
final previewFrameFuture = useMemoized(
() => posterUrl == null
? loadPreviewFrame(url)
: Future<LoadedVideoPreviewFrame?>.value(),
[loadPreviewFrame, posterUrl, url],
);
useEffect(() {
var disposed = false;
LoadedVideoPreviewFrame? loadedFrame;
unawaited(
previewFrameFuture.then((frame) {
if (disposed) {
if (frame != null) unawaited(frame.dispose());
} else {
loadedFrame = frame;
}
}),
);
return () {
disposed = true;
final frame = loadedFrame;
if (frame != null) unawaited(frame.dispose());
};
}, [previewFrameFuture]);
return Padding(
padding: const EdgeInsets.only(top: Grid.half),
child: GestureDetector(
onTap: () => openVideoViewer(
context,
videoUrl: url,
posterUrl: posterUrl,
onReply: onReply,
),
child: _MessageMediaPreviewFrame(
previewKey: ValueKey('message-media-video-preview:$url'),
backgroundColor: Colors.black,
child: AspectRatio(
aspectRatio: aspectRatio.toDouble(),
child: Stack(
fit: StackFit.expand,
children: [
if (posterUrl != null)
MediaImage(
url: posterUrl,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _MediaPreviewFallback(
icon: LucideIcons.video,
label: context.l10n.videoPreviewUnavailable,
),
)
else
FutureBuilder<LoadedVideoPreviewFrame?>(
future: previewFrameFuture,
builder: (context, snapshot) {
final frame = snapshot.data;
if (frame == null) {
return _MediaPreviewFallback(
icon: LucideIcons.video,
label: context.l10n.videoAttachment,
);
}
return ColoredBox(
key: ValueKey('message-media-video-first-frame:$url'),
color: Colors.black,
child: Center(
child: AspectRatio(
aspectRatio: frame.aspectRatio,
child: frame.child,
),
),
);
},
),
const ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.28)),
Center(
child: Container(
width: 52,
height: 52,
decoration: const BoxDecoration(
color: Color.fromRGBO(0, 0, 0, 0.6),
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.play,
color: Colors.white,
size: 24,
),
),
),
Positioned(
left: Grid.xxs,
right: Grid.xxs,
bottom: Grid.xxs,
child: Text(
context.l10n.video,
style: context.textTheme.labelSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
),
);
}
}
Future<LoadedVideoPreviewFrame?> _loadVideoPreviewFrame(
String url, {
required Map<String, String> headers,
}) async {
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme) return null;
VideoPlayerController? controller;
Future<void> disposeResources() async {
final currentController = controller;
controller = null;
if (currentController != null) await currentController.dispose();
}
Future<bool> initialize(VideoPlayerController candidate) async {
controller = candidate;
await candidate.initialize();
final duration = candidate.value.duration;
if (duration > const Duration(milliseconds: 100)) {
await candidate.seekTo(const Duration(milliseconds: 100));
}
await candidate.pause();
return candidate.value.isInitialized;
}
try {
final networkController = VideoPlayerController.networkUrl(
uri,
httpHeaders: headers,
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
);
if (!await initialize(networkController)) {
await disposeResources();
return null;
}
} on MissingPluginException {
await disposeResources();
return null;
} on UnimplementedError {
await disposeResources();
return null;
} catch (_) {
// A timeline preview must stay bounded: falling back to a local file here
// would fully download every posterless video encountered while scrolling.
// The full-screen viewer can use its authenticated fallback after a tap.
await disposeResources();
return null;
}
final initializedController = controller;
if (initializedController == null) return null;
var didDispose = false;
return LoadedVideoPreviewFrame(
aspectRatio: initializedController.value.aspectRatio > 0
? initializedController.value.aspectRatio
: 16 / 9,
child: VideoPlayer(initializedController),
dispose: () async {
if (didDispose) return;
didDispose = true;
await disposeResources();
},
);
}