diff --git a/lib/pages/stream.dart b/lib/pages/stream.dart index e9df81e..f665fd5 100644 --- a/lib/pages/stream.dart +++ b/lib/pages/stream.dart @@ -1,11 +1,7 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; import 'package:ndk/ndk.dart'; -import 'package:video_player/video_player.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:zap_stream_flutter/imgproxy.dart'; -import 'package:zap_stream_flutter/main.dart'; import 'package:zap_stream_flutter/rx_filter.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; @@ -14,6 +10,7 @@ import 'package:zap_stream_flutter/widgets/chat.dart'; import 'package:zap_stream_flutter/widgets/pill.dart'; import 'package:zap_stream_flutter/widgets/profile.dart'; import 'package:zap_stream_flutter/widgets/stream_info.dart'; +import 'package:zap_stream_flutter/widgets/video_player.dart'; import 'package:zap_stream_flutter/widgets/zap.dart'; class StreamPage extends StatefulWidget { @@ -26,49 +23,16 @@ class StreamPage extends StatefulWidget { } class _StreamPage extends State { - VideoPlayerController? _controller; - ChewieController? _chewieController; - @override void initState() { super.initState(); - WakelockPlus.enable(); - final url = widget.stream.info.stream; - - if (url != null) { - if (_controller != null) { - _controller!.dispose(); - } - _controller = VideoPlayerController.networkUrl( - Uri.parse(url), - httpHeaders: Map.from({"user-agent": userAgent}), - ); - () async { - await _controller!.initialize(); - setState(() { - _chewieController = ChewieController( - videoPlayerController: _controller!, - aspectRatio: 16 / 9, - autoPlay: true, - placeholder: - (widget.stream.info.image?.isNotEmpty ?? false) - ? ProxyImg(url: widget.stream.info.image!) - : null, - ); - }); - }(); - } } @override void dispose() { super.dispose(); WakelockPlus.disable(); - if (_controller != null) { - _controller!.dispose(); - _controller = null; - } } @override @@ -98,18 +62,14 @@ class _StreamPage extends State { AspectRatio( aspectRatio: 16 / 9, child: - _chewieController != null - ? Chewie( - key: Key("stream:player:${stream.aTag}"), - controller: _chewieController!, + stream.info.stream != null + ? VideoPlayerWidget( + url: stream.info.stream!, + placeholder: stream.info.image, ) - : Container( - color: LAYER_1, - child: - (stream.info.image?.isNotEmpty ?? false) - ? ProxyImg(url: stream.info.image!) - : null, - ), + : (stream.info.image?.isNotEmpty ?? false) + ? ProxyImg(url: stream.info.image) + : Container(decoration: BoxDecoration(color: LAYER_1)), ), Text( stream.info.title ?? "", diff --git a/lib/utils.dart b/lib/utils.dart index dbb9e39..9cbcf23 100644 --- a/lib/utils.dart +++ b/lib/utils.dart @@ -6,6 +6,7 @@ import 'package:convert/convert.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip19/hrps.dart'; import 'package:ndk/shared/nips/nip19/nip19.dart'; /// Container class over event and stream info @@ -378,7 +379,7 @@ String formatSecondsToHHMMSS(int seconds) { int hours = seconds ~/ 3600; int minutes = (seconds % 3600) ~/ 60; int remainingSeconds = seconds % 60; - + return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${remainingSeconds.toString().padLeft(2, '0')}'; } @@ -394,6 +395,95 @@ String bech32ToHex(String bech32) { } } +// https://github.com/nostr-protocol/nips/blob/master/19.md +class TLVTypes { + static const int kSpecial = 0; + static const int kRelay = 1; + static const int kAuthor = 2; + static const int kKind = 3; +} + +class TLVEntity { + final String hrp; + final List data; + + const TLVEntity(this.hrp, this.data); + + TLV? get special { + return data.firstWhereOrNull((e) => e.type == TLVTypes.kSpecial); + } + + /// return the special entry as hex + String? get specialHex { + final r = special; + return r != null ? hex.encode(r.value) : null; + } + + /// return the special entry as utf8 string + String? get specialUtf8 { + final r = special; + return r != null ? utf8.decode(r.value) : null; + } + + int? get kind { + final k = data.firstWhereOrNull((e) => e.type == TLVTypes.kKind); + return k != null + ? k.value[0] << 24 | k.value[1] << 16 | k.value[2] << 8 | k.value[3] + : null; + } + + String? get author { + final a = data.firstWhereOrNull((e) => e.type == TLVTypes.kAuthor); + return a != null ? hex.encode(a.value) : null; + } + + List? get relays { + final r = data.where((r) => r.type == TLVTypes.kRelay); + if (r.isNotEmpty) { + return r.map((e) => utf8.decode(e.value)).toList(); + } + return null; + } + + Filter toFilter() { + var ret = {}; + if (hrp == Hrps.kNaddr) { + final dTag = specialUtf8; + final kindValue = kind; + final authorValue = author; + if (dTag == null || kindValue == null || authorValue == null) { + throw "Invalid naddr entity, special, kind and author must be set"; + } + ret["#d"] = [dTag]; + ret["authors"] = [authorValue]; + ret["kinds"] = [kindValue]; + } else if (hrp == Hrps.kNevent) { + final idValue = specialHex; + if (idValue == null) { + throw "Invalid nevent, special entry is invalid or missing"; + } + ret["ids"] = [idValue]; + final kindValue = kind; + if (kindValue != null) { + ret["kinds"] = [kindValue]; + } + final authorValue = author; + if (authorValue != null) { + ret["authors"] = [authorValue]; + } + } else if (hrp == Hrps.kNoteId) { + final idValue = specialHex; + if (idValue == null) { + throw "Invalid nevent, special entry is invalid or missing"; + } + ret["ids"] = [idValue]; + } else { + throw "Cant convert $hrp to a filter"; + } + return Filter.fromMap(ret); + } +} + class TLV { final int type; final int length; @@ -478,3 +568,15 @@ String encodeBech32TLV(String hrp, List tlvs) { throw FormatException('Failed to encode Bech32 or TLV: $e'); } } + +TLVEntity decodeBech32ToTLVEntity(String input) { + final decoder = Bech32Decoder(); + final data = decoder.convert(input, 10_000); + final data8bit = Nip19.convertBits(data.data, 5, 8, false); + if (data.hrp != "npub" || data.hrp != "nsec" || data.hrp != "note") { + return TLVEntity(data.hrp, parseTLV(data8bit)); + } else { + // convert to basic type using special entry only + return TLVEntity(data.hrp, [TLV(0, data8bit.length, data8bit)]); + } +} diff --git a/lib/widgets/chat_modal.dart b/lib/widgets/chat_modal.dart index 0a0b7e1..fb5dd2d 100644 --- a/lib/widgets/chat_modal.dart +++ b/lib/widgets/chat_modal.dart @@ -38,7 +38,7 @@ class _ChatModalWidget extends State { width: double.maxFinite, decoration: BoxDecoration(color: LAYER_2, borderRadius: DEFAULT_BR), padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: NoteText(event: widget.event), + child: NoteText(event: widget.event, showEmbeds: false), ), Row( spacing: 8, diff --git a/lib/widgets/nostr_text.dart b/lib/widgets/nostr_text.dart index 82f03ba..767b381 100644 --- a/lib/widgets/nostr_text.dart +++ b/lib/widgets/nostr_text.dart @@ -1,24 +1,45 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:go_router/go_router.dart'; import 'package:ndk/ndk.dart'; import 'package:ndk/shared/nips/nip19/nip19.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:zap_stream_flutter/imgproxy.dart'; import 'package:zap_stream_flutter/theme.dart'; import 'package:zap_stream_flutter/utils.dart'; import 'package:zap_stream_flutter/widgets/custom_emoji.dart'; +import 'package:zap_stream_flutter/widgets/note_embed.dart'; import 'package:zap_stream_flutter/widgets/profile.dart'; +import 'package:zap_stream_flutter/widgets/video_player.dart'; class NoteText extends StatelessWidget { final Nip01Event event; + final bool? embedMedia; + final bool? showEmbeds; - const NoteText({super.key, required this.event}); + const NoteText({ + super.key, + required this.event, + this.embedMedia, + this.showEmbeds, + }); @override Widget build(BuildContext context) { + // use markdown rendering for articles + if (event.kind == 30_023) { + return MarkdownBody(data: event.content); + } return RichText( text: TextSpan( - children: textToSpans(event.content, event.tags, event.pubKey), + children: textToSpans( + event.content, + event.tags, + event.pubKey, + showEmbeds: showEmbeds, + embedMedia: embedMedia, + ), ), ); } @@ -26,21 +47,18 @@ class NoteText extends StatelessWidget { /// Converts a nostr note text containing links /// and mentions into multiple spans for rendering +/// /// https://github.com/leo-lox/camelus/blob/f58455a0ac07fcc780bdc69b8f4544fd5ea4a46d/lib/presentation_layer/components/note_card/note_card_build_split_content.dart#L262 List textToSpans( String content, List> tags, - String pubkey, -) { - return _buildContentSpans(content.trim(), tags); -} - -/// Content parser from camelus -/// https://github.com/leo-lox/camelus/blob/f58455a0ac07fcc780bdc69b8f4544fd5ea4a46d/lib/presentation_layer/components/note_card/note_card_build_split_content.dart#L262 -List _buildContentSpans(String content, List> tags) { + String pubkey, { + bool? showEmbeds, + bool? embedMedia, +}) { List spans = []; RegExp exp = RegExp( r'nostr:(nprofile|npub)[a-zA-Z0-9]+|' - r'nostr:(note|nevent)[a-zA-Z0-9]+|' + r'nostr:(note|nevent|naddr)[a-zA-Z0-9]+|' r'(#\$\$\s*[0-9]+\s*\$\$)|' r'(#\w+)|' // Hashtags r'(https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*))', // URLs @@ -53,11 +71,11 @@ List _buildContentSpans(String content, List> tags) { String? matched = match.group(0); if (matched != null) { if (matched.startsWith('nostr:')) { - spans.add(_buildProfileOrNoteSpan(matched)); + spans.add(_buildProfileOrNoteSpan(matched, showEmbeds ?? true)); } else if (matched.startsWith('#')) { spans.add(_buildHashtagSpan(matched)); } else if (matched.startsWith('http')) { - spans.add(_buildUrlSpan(matched)); + spans.add(_buildUrlSpan(matched, embedMedia ?? false)); } } return ''; @@ -87,12 +105,14 @@ List _buildContentSpans(String content, List> tags) { return spans; } -InlineSpan _buildProfileOrNoteSpan(String word) { +InlineSpan _buildProfileOrNoteSpan(String word, bool showEmbeds) { final cleanedWord = word.replaceAll('nostr:', ''); final isProfile = cleanedWord.startsWith('nprofile') || cleanedWord.startsWith('npub'); final isNote = - cleanedWord.startsWith('note') || cleanedWord.startsWith('nevent'); + cleanedWord.startsWith('note') || + cleanedWord.startsWith('nevent') || + cleanedWord.startsWith("naddr"); if (isProfile) { final hexKey = bech32ToHex(cleanedWord); @@ -102,11 +122,13 @@ InlineSpan _buildProfileOrNoteSpan(String word) { return TextSpan(text: "@$cleanedWord"); } } - if (isNote) { - final eventId = bech32ToHex(cleanedWord); - return TextSpan(text: eventId, style: TextStyle(color: PRIMARY_1)); + if (isNote && showEmbeds) { + return WidgetSpan( + child: NoteEmbedWidget(link: cleanedWord), + alignment: PlaceholderAlignment.middle, + ); } else { - return TextSpan(text: word); + return TextSpan(text: word, style: TextStyle(color: PRIMARY_1)); } } @@ -114,7 +136,29 @@ InlineSpan _buildHashtagSpan(String word) { return TextSpan(text: word, style: TextStyle(color: PRIMARY_1)); } -InlineSpan _buildUrlSpan(String url) { +InlineSpan _buildUrlSpan(String url, bool embedMedia) { + if (embedMedia && + (url.endsWith(".jpg") || + url.endsWith(".gif") || + url.endsWith(".jpeg") || + url.endsWith(".webp") || + url.endsWith(".png") || + url.endsWith(".bmp"))) { + return WidgetSpan(child: ProxyImg(url: url)); + } + if (embedMedia && + (url.endsWith(".mp4") || + url.endsWith(".mov") || + url.endsWith(".webm") || + url.endsWith(".mkv") || + url.endsWith(".m3u8"))) { + return WidgetSpan( + child: AspectRatio( + aspectRatio: 16 / 9, + child: Center(child: VideoPlayerWidget(url: url, autoPlay: false)), + ), + ); + } return TextSpan( text: url, style: TextStyle(color: PRIMARY_1), diff --git a/lib/widgets/note_embed.dart b/lib/widgets/note_embed.dart new file mode 100644 index 0000000..46b0381 --- /dev/null +++ b/lib/widgets/note_embed.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ndk/ndk.dart'; +import 'package:zap_stream_flutter/rx_filter.dart'; +import 'package:zap_stream_flutter/theme.dart'; +import 'package:zap_stream_flutter/utils.dart'; +import 'package:zap_stream_flutter/widgets/nostr_text.dart'; +import 'package:zap_stream_flutter/widgets/pill.dart'; +import 'package:zap_stream_flutter/widgets/profile.dart'; + +class NoteEmbedWidget extends StatelessWidget { + final String link; + + const NoteEmbedWidget({super.key, required this.link}); + + @override + Widget build(BuildContext context) { + final entity = decodeBech32ToTLVEntity(link); + + return RxFilter( + Key("embeded-note:$link"), + filters: [entity.toFilter()], + builder: (context, data) { + final note = data != null && data.isNotEmpty ? data.first : null; + return PillWidget( + onTap: () { + if (note != null) { + // redirect to the stream if its a live stream link + if (note.kind == 30_311) { + context.push("/e/$link", extra: StreamEvent(note)); + return; + } + showModalBottomSheet( + context: context, + builder: (context) { + return SingleChildScrollView(child: _NotePreview(note: note)); + }, + ); + } + }, + color: LAYER_3, + child: RichText( + text: TextSpan( + children: [ + WidgetSpan(child: Icon(Icons.link, size: 16)), + TextSpan( + text: switch (entity.kind) { + 30_023 => " Article by ", + 30_311 => " Live Stream by ", + _ => " Note by ", + }, + ), + if (note?.pubKey != null) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: ProfileNameWidget.pubkey(switch (note!.kind) { + 30_311 => StreamEvent(note).info.host, + _ => note.pubKey, + }, linkToProfile: false), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _NotePreview extends StatelessWidget { + final Nip01Event note; + + const _NotePreview({required this.note}); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(10), + decoration: BoxDecoration(color: LAYER_1, borderRadius: DEFAULT_BR), + child: Column( + spacing: 8, + children: [ + ProfileWidget.pubkey(note.pubKey), + NoteText(event: note, embedMedia: true), + ], + ), + ); + } +} diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index 8c623b9..23a1f31 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -24,15 +24,27 @@ class ProfileLoaderWidget extends StatelessWidget { class ProfileNameWidget extends StatelessWidget { final Metadata profile; final TextStyle? style; + final bool? linkToProfile; - const ProfileNameWidget({super.key, required this.profile, this.style}); + const ProfileNameWidget({ + super.key, + required this.profile, + this.style, + this.linkToProfile, + }); - static Widget pubkey(String pubkey, {Key? key, TextStyle? style}) { + static Widget pubkey( + String pubkey, { + Key? key, + TextStyle? style, + bool? linkToProfile, + }) { return ProfileLoaderWidget( pubkey, (ctx, data) => ProfileNameWidget( profile: data.data ?? Metadata(pubKey: pubkey), style: style, + linkToProfile: linkToProfile, ), key: key, ); @@ -50,14 +62,22 @@ class ProfileNameWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( - onTap: - () => context.push( - "/p/${Nip19.encodePubKey(profile.pubKey)}", - extra: profile, - ), - child: Text(ProfileNameWidget.nameFromProfile(profile), style: style), + final inner = Text( + ProfileNameWidget.nameFromProfile(profile), + style: style, ); + if (linkToProfile ?? true) { + return GestureDetector( + onTap: + () => context.push( + "/p/${Nip19.encodePubKey(profile.pubKey)}", + extra: profile, + ), + child: inner, + ); + } else { + return inner; + } } } diff --git a/lib/widgets/video_player.dart b/lib/widgets/video_player.dart new file mode 100644 index 0000000..e45d847 --- /dev/null +++ b/lib/widgets/video_player.dart @@ -0,0 +1,57 @@ +import 'package:chewie/chewie.dart'; +import 'package:flutter/widgets.dart'; +import 'package:video_player/video_player.dart'; +import 'package:zap_stream_flutter/imgproxy.dart'; +import 'package:zap_stream_flutter/main.dart'; + +class VideoPlayerWidget extends StatefulWidget { + final String url; + final String? placeholder; + final double? aspectRatio; + final bool? autoPlay; + + const VideoPlayerWidget({ + super.key, + required this.url, + this.placeholder, + this.aspectRatio, + this.autoPlay, + }); + + @override + State createState() => _VideoPlayerWidget(); +} + +class _VideoPlayerWidget extends State { + late final ChewieController _chewieController; + + @override + void initState() { + super.initState(); + + final controller = VideoPlayerController.networkUrl( + Uri.parse(widget.url), + httpHeaders: Map.from({"user-agent": userAgent}), + ); + _chewieController = ChewieController( + videoPlayerController: controller, + autoPlay: widget.autoPlay ?? true, + autoInitialize: true, + placeholder: + (widget.placeholder?.isNotEmpty ?? false) + ? ProxyImg(url: widget.placeholder!) + : null, + ); + } + + @override + void dispose() { + super.dispose(); + _chewieController.dispose(); + } + + @override + Widget build(BuildContext context) { + return Chewie(controller: _chewieController); + } +}