From 6bb19ce1f5e6de4199935ebeb57928b537130105 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 3 Jun 2025 13:54:56 +0100 Subject: [PATCH] feat: protrait video style --- lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_en.g.dart | 12 ++ lib/i18n/translated/en.i18n.yaml | 2 + lib/pages/stream.dart | 251 +++++++++++++++++++---------- lib/player.dart | 121 ++++++++++---- lib/widgets/chat.dart | 18 +-- lib/widgets/video_player_main.dart | 27 +++- 7 files changed, 305 insertions(+), 130 deletions(-) diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index d94919b..451fdea 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 22 -/// Strings: 2010 (91 per locale) +/// Strings: 2011 (91 per locale) /// -/// Built on 2025-05-30 at 13:17 UTC +/// Built on 2025-06-03 at 10:11 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 84992f6..8cb6b87 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -86,6 +86,7 @@ class TranslationsStreamEn { String started({required Object timestamp}) => 'Started ${timestamp}'; String notification({required Object name}) => '${name} went live!'; late final TranslationsStreamChatEn chat = TranslationsStreamChatEn.internal(_root); + late final TranslationsStreamErrorEn error = TranslationsStreamErrorEn.internal(_root); } // Path: goal @@ -280,6 +281,16 @@ class TranslationsStreamChatEn { late final TranslationsStreamChatRaidEn raid = TranslationsStreamChatRaidEn.internal(_root); } +// Path: stream.error +class TranslationsStreamErrorEn { + TranslationsStreamErrorEn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + String load_failed({required Object url}) => 'Failed to load stream from ${url}'; +} + // Path: zap.error class TranslationsZapErrorEn { TranslationsZapErrorEn.internal(this._root); @@ -455,6 +466,7 @@ extension on Translations { case 'stream.chat.raid.to': return ({required Object name}) => 'RAIDING ${name}'; case 'stream.chat.raid.from': return ({required Object name}) => 'RAID FROM ${name}'; case 'stream.chat.raid.countdown': return ({required Object time}) => 'Raiding in ${time}'; + case 'stream.error.load_failed': return ({required Object url}) => 'Failed to load stream from ${url}'; case 'goal.title': return ({required Object amount}) => 'Goal: ${amount}'; case 'goal.remaining': return ({required Object amount}) => 'Remaining: ${amount}'; case 'goal.complete': return 'COMPLETE'; diff --git a/lib/i18n/translated/en.i18n.yaml b/lib/i18n/translated/en.i18n.yaml index d25fc65..96b18a0 100644 --- a/lib/i18n/translated/en.i18n.yaml +++ b/lib/i18n/translated/en.i18n.yaml @@ -59,6 +59,8 @@ stream: countdown: Raiding in ${time} "@countdown": description: Countdown timer for auto-raiding + error: + load_failed: Failed to load stream from ${url} goal: title: "Goal: $amount" remaining: "Remaining: $amount" diff --git a/lib/pages/stream.dart b/lib/pages/stream.dart index df0269b..5eb18ec 100644 --- a/lib/pages/stream.dart +++ b/lib/pages/stream.dart @@ -8,6 +8,7 @@ import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/imgproxy.dart'; import 'package:zap_stream_flutter/const.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'; @@ -56,6 +57,7 @@ class StreamPage extends StatefulWidget { class _StreamPage extends State with RouteAware { bool _offScreen = false; + StreamEvent? _stream; bool isWidgetVisible(BuildContext context) { final router = GoRouter.of(context); @@ -127,97 +129,184 @@ class _StreamPage extends State with RouteAware { ], builder: (ctx, state) { final stream = StreamEvent(state?.firstOrNull ?? widget.stream.event); - return _buildStream(context, stream); + final streamWidget = _buildPlayer(ctx, stream); + return ValueListenableBuilder( + valueListenable: mainPlayer.state, + builder: (context, state, player) { + if (state?.isPortrait == true) { + return _buildPortraitStream(context, stream, player!); + } + return _buildLandscapeStream(context, stream, player!); + }, + child: streamWidget, + ); }, ); } - Widget _buildStream(BuildContext context, StreamEvent stream) { + Widget _buildPlayer(BuildContext context, StreamEvent stream) { + return (stream.info.stream != null && !_offScreen) + ? MainVideoPlayerWidget( + url: stream.info.stream!, + placeholder: stream.info.image, + isLive: true, + title: stream.info.title, + ) + : AspectRatio( + aspectRatio: 16 / 9, + child: + (stream.info.image?.isNotEmpty ?? false) + ? ProxyImg(url: stream.info.image) + : Container(decoration: BoxDecoration(color: LAYER_1)), + ); + } + + Widget _buildPortraitStream( + BuildContext context, + StreamEvent stream, + Widget child, + ) { + final mq = MediaQuery.of(context); + + return Stack( + children: [ + child, + Positioned(child: child), + Positioned( + child: Container( + padding: EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + LAYER_0.withAlpha(50), + LAYER_0.withAlpha(200), + LAYER_0.withAlpha(255), + ], + stops: [0.0, 0.2, 1.0], + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: _streamInfo(context, stream), + ), + ), + ), + Positioned( + bottom: 10, + left: 0, + child: Container( + width: mq.size.width, + height: mq.size.height * 0.4, + padding: EdgeInsets.symmetric(horizontal: 10), + child: ShaderMask( + shaderCallback: (Rect bounds) { + return LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + LAYER_0.withAlpha(255), + LAYER_0.withAlpha(200), + LAYER_0.withAlpha(0), + ], + stops: [0.0, 0.8, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: ChatWidget( + stream: stream, + showGoals: false, + showTopZappers: false, + ), + ), + ), + ), + ], + ); + } + + Widget _buildLandscapeStream( + BuildContext context, + StreamEvent stream, + Widget child, + ) { return Column( spacing: 4, crossAxisAlignment: CrossAxisAlignment.start, children: [ - AspectRatio( - aspectRatio: 16 / 9, - child: - (stream.info.stream != null && !_offScreen) - ? MainVideoPlayerWidget( - url: stream.info.stream!, - placeholder: stream.info.image, - isLive: true, - title: stream.info.title, - ) - : (stream.info.image?.isNotEmpty ?? false) - ? ProxyImg(url: stream.info.image) - : Container(decoration: BoxDecoration(color: LAYER_1)), - ), - if (stream.info.title?.isNotEmpty ?? false) - Text( - stream.info.title!, - style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18), - ), - ProfileWidget.pubkey( - stream.info.host, - children: [ - NotificationsButtonWidget(pubkey: widget.stream.info.host), - BasicButton( - Row( - children: [Icon(Icons.bolt, size: 14), Text(t.zap.button_zap)], - ), - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 2), - decoration: BoxDecoration( - color: PRIMARY_1, - borderRadius: DEFAULT_BR, - ), - onTap: (context) { - showModalBottomSheet( - context: context, - constraints: BoxConstraints.expand(), - builder: (context) { - return SingleChildScrollView( - primary: false, - child: ZapWidget( - pubkey: stream.info.host, - target: stream.event, - onPaid: (_) { - context.pop(); - }, - zapTags: - // tag goal onto zap request - stream.info.goal != null - ? [ - ["e", stream.info.goal!], - ] - : null, - ), - ); - }, - ); - }, - ), - if (stream.info.participants != null) - PillWidget( - color: LAYER_1, - child: Text( - t.viewers(n: stream.info.participants!), - style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500), - ), - ), - GestureDetector( - onTap: () { - showModalBottomSheet( - context: context, - constraints: BoxConstraints.expand(), - isScrollControlled: true, - builder: (context) => StreamInfoWidget(stream: stream), - ); - }, - child: Icon(Icons.info), - ), - ], - ), + child, + ..._streamInfo(context, stream), Expanded(child: ChatWidget(stream: stream)), ], ); } + + List _streamInfo(BuildContext context, StreamEvent stream) { + return [ + if (stream.info.title?.isNotEmpty ?? false) + Text( + stream.info.title!, + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18), + ), + ProfileWidget.pubkey( + stream.info.host, + children: [ + NotificationsButtonWidget(pubkey: widget.stream.info.host), + BasicButton( + Row(children: [Icon(Icons.bolt, size: 14), Text(t.zap.button_zap)]), + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 2), + decoration: BoxDecoration( + color: PRIMARY_1, + borderRadius: DEFAULT_BR, + ), + onTap: (context) { + showModalBottomSheet( + context: context, + constraints: BoxConstraints.expand(), + builder: (context) { + return SingleChildScrollView( + primary: false, + child: ZapWidget( + pubkey: stream.info.host, + target: stream.event, + onPaid: (_) { + context.pop(); + }, + zapTags: + // tag goal onto zap request + stream.info.goal != null + ? [ + ["e", stream.info.goal!], + ] + : null, + ), + ); + }, + ); + }, + ), + if (stream.info.participants != null) + PillWidget( + color: LAYER_1, + child: Text( + t.viewers(n: stream.info.participants!), + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + ), + GestureDetector( + onTap: () { + showModalBottomSheet( + context: context, + constraints: BoxConstraints.expand(), + isScrollControlled: true, + builder: (context) => StreamInfoWidget(stream: stream), + ); + }, + child: Icon(Icons.info), + ), + ], + ), + ]; + } } diff --git a/lib/player.dart b/lib/player.dart index b9d457d..d05df3e 100644 --- a/lib/player.dart +++ b/lib/player.dart @@ -1,12 +1,36 @@ +import 'dart:developer' as developer; + import 'package:audio_service/audio_service.dart'; import 'package:chewie/chewie.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; import 'package:video_player/video_player.dart'; import 'package:zap_stream_flutter/const.dart'; +import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/imgproxy.dart'; +class PlayerState { + final int? width; + final int? height; + final bool isPlaying; + final Exception? error; + + bool get isPortrait { + return width != null && height != null ? width! / height! < 1.0 : false; + } + + const PlayerState({ + this.width, + this.height, + this.isPlaying = false, + this.error, + }); +} + class MainPlayer extends BaseAudioHandler { VideoPlayerController? _controller; ChewieController? _chewieController; + ValueNotifier state = ValueNotifier(null); ChewieController? get chewie { return _chewieController; @@ -27,7 +51,7 @@ class MainPlayer extends BaseAudioHandler { await _chewieController?.pause(); } - void loadUrl( + Future loadUrl( String url, { String? title, bool? autoPlay, @@ -35,42 +59,64 @@ class MainPlayer extends BaseAudioHandler { bool? isLive, String? placeholder, String? artist, - }) { - if (_chewieController != null) { - _chewieController!.dispose(); - _controller!.dispose(); + }) async { + if (_controller?.dataSource == url) { + return; } + try { + developer.log("PLAYER loading $url"); + if (_chewieController != null) { + _controller!.removeListener(updatePlayerState); + await _controller!.dispose(); + _controller = null; + _chewieController!.dispose(); + _chewieController = null; + } + state.value = null; + _controller = VideoPlayerController.networkUrl( + Uri.parse(url), + httpHeaders: Map.from({"user-agent": userAgent}), + videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: true), + ); + await _controller!.initialize(); + _controller!.addListener(updatePlayerState); + _chewieController = ChewieController( + videoPlayerController: _controller!, + autoPlay: autoPlay ?? true, + aspectRatio: aspectRatio, + isLive: isLive ?? false, + allowedScreenSleep: false, + placeholder: + (placeholder?.isNotEmpty ?? false) + ? ProxyImg(url: placeholder!) + : null, + ); - _controller = VideoPlayerController.networkUrl( - Uri.parse(url), - httpHeaders: Map.from({"user-agent": userAgent}), - videoPlayerOptions: VideoPlayerOptions(allowBackgroundPlayback: true), - ); - _chewieController = ChewieController( - videoPlayerController: _controller!, - autoPlay: autoPlay ?? true, - aspectRatio: aspectRatio, - isLive: isLive ?? false, - autoInitialize: true, - allowedScreenSleep: false, - placeholder: - (placeholder?.isNotEmpty ?? false) - ? ProxyImg(url: placeholder!) - : null, - ); - - // insert media item - mediaItem.add( - MediaItem( - id: url.hashCode.toString(), - title: title ?? url, - artist: artist, - isLive: _chewieController!.isLive, - artUri: - (placeholder?.isNotEmpty ?? false) ? Uri.parse(placeholder!) : null, - ), - ); - _chewieController!.videoPlayerController.addListener(updatePlayerState); + // insert media item + mediaItem.add( + MediaItem( + id: url.hashCode.toString(), + title: title ?? url, + artist: artist, + isLive: _chewieController!.isLive, + artUri: + (placeholder?.isNotEmpty ?? false) + ? Uri.parse(placeholder!) + : null, + ), + ); + } catch (e) { + if (e is PlatformException && e.code == "VideoError") { + state.value = PlayerState( + error: Exception(t.stream.error.load_failed(url: url)), + ); + } else { + state.value = PlayerState( + error: e is Exception ? e : Exception(e.toString()), + ); + } + developer.log("Failed to start player: ${e.toString()}"); + } } void updatePlayerState() { @@ -97,5 +143,10 @@ class MainPlayer extends BaseAudioHandler { }, ), ); + state.value = PlayerState( + width: _controller!.value.size.width.floor(), + height: _controller!.value.size.height.floor(), + isPlaying: isPlaying, + ); } } diff --git a/lib/widgets/chat.dart b/lib/widgets/chat.dart index 5065da9..288de0d 100644 --- a/lib/widgets/chat.dart +++ b/lib/widgets/chat.dart @@ -18,16 +18,16 @@ import 'package:zap_stream_flutter/widgets/profile.dart'; class ChatWidget extends StatelessWidget { final StreamEvent stream; - final bool? showGoals; - final bool? showTopZappers; - final bool? showRaids; + final bool showGoals; + final bool showTopZappers; + final bool showRaids; const ChatWidget({ super.key, required this.stream, - this.showGoals, - this.showTopZappers, - this.showRaids, + this.showGoals = true, + this.showTopZappers = true, + this.showRaids = true, }); @override @@ -40,7 +40,7 @@ class ChatWidget extends StatelessWidget { var filters = [ Filter(kinds: [1311, 9735], limit: 200, aTags: [stream.aTag]), - if (showRaids ?? true) + if (showRaids) Filter(kinds: [1312, 1313], limit: 200, aTags: [stream.aTag]), Filter(kinds: [Nip51List.kMute], authors: moderators), Filter(kinds: [1314], authors: moderators), @@ -118,9 +118,9 @@ class ChatWidget extends StatelessWidget { spacing: 8, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (zaps.isNotEmpty && (showTopZappers ?? true)) + if (zaps.isNotEmpty && showTopZappers) _TopZappersWidget(events: zaps), - if (stream.info.goal != null && (showGoals ?? true)) + if (stream.info.goal != null && showGoals) GoalWidget.id(stream.info.goal!), Expanded( child: ListView.builder( diff --git a/lib/widgets/video_player_main.dart b/lib/widgets/video_player_main.dart index e3e6e3f..8223e07 100644 --- a/lib/widgets/video_player_main.dart +++ b/lib/widgets/video_player_main.dart @@ -1,6 +1,7 @@ import 'package:chewie/chewie.dart'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:zap_stream_flutter/main.dart'; +import 'package:zap_stream_flutter/theme.dart'; class MainVideoPlayerWidget extends StatefulWidget { final String url; @@ -34,7 +35,7 @@ class _MainVideoPlayerWidget extends State { aspectRatio: widget.aspectRatio, autoPlay: widget.autoPlay, isLive: widget.isLive, - artist: "zap.stream" + artist: "zap.stream", ); super.initState(); @@ -48,6 +49,26 @@ class _MainVideoPlayerWidget extends State { @override Widget build(BuildContext context) { - return Chewie(controller: mainPlayer.chewie!); + return ValueListenableBuilder( + valueListenable: mainPlayer.state, + builder: (context, state, _) { + final innerWidget = + mainPlayer.chewie != null + ? Chewie(controller: mainPlayer.chewie!) + : Center( + child: + state?.error != null + ? Text( + state!.error.toString(), + style: TextStyle(color: WARNING), + ) + : CircularProgressIndicator(), + ); + if (state?.isPortrait == true) { + return innerWidget; + } + return AspectRatio(aspectRatio: 16 / 9, child: innerWidget); + }, + ); } }