feat: protrait video style

This commit is contained in:
2025-06-03 13:54:56 +01:00
parent f15e693a54
commit 6bb19ce1f5
7 changed files with 305 additions and 130 deletions

View File

@ -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

View File

@ -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';

View File

@ -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"

View File

@ -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<StreamPage> with RouteAware {
bool _offScreen = false;
StreamEvent? _stream;
bool isWidgetVisible(BuildContext context) {
final router = GoRouter.of(context);
@ -127,97 +129,184 @@ class _StreamPage extends State<StreamPage> 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<Widget> _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),
),
],
),
];
}
}

View File

@ -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<PlayerState?> state = ValueNotifier(null);
ChewieController? get chewie {
return _chewieController;
@ -27,7 +51,7 @@ class MainPlayer extends BaseAudioHandler {
await _chewieController?.pause();
}
void loadUrl(
Future<void> 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,
);
}
}

View File

@ -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(

View File

@ -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<MainVideoPlayerWidget> {
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<MainVideoPlayerWidget> {
@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);
},
);
}
}