mirror of
https://github.com/nostrlabs-io/zap-stream-flutter.git
synced 2025-06-14 11:27:43 +00:00
feat: protrait video style
This commit is contained in:
@ -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
|
||||
|
@ -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';
|
||||
|
@ -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"
|
||||
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
121
lib/player.dart
121
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<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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user