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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
229 lines
7.2 KiB
Dart
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();
|
|
},
|
|
);
|
|
}
|