9 Commits
v1.1.1 ... main

Author SHA1 Message Date
a66eb8c338 chore: bump version 2025-06-07 10:48:43 +01:00
f570f4c1f2 fix: range error 2025-06-07 10:48:29 +01:00
71563bc5ce chore: ensure flutter version 3.32+ 2025-06-06 21:53:21 +01:00
b5b5ef0daf chore: bump version 2025-06-05 12:05:39 +01:00
3b07f7d073 feat: improve vertical player design 2025-06-05 12:05:07 +01:00
64655ba21c fix: handle short urls
closes #52
2025-06-05 11:47:32 +01:00
5ffab5f7bf fix: back press gesture blocked
fix: portrait player state issue
closes #49
2025-06-05 11:26:49 +01:00
0d8ce78009 chore: bump version 2025-06-03 15:27:52 +01:00
ef1c03d8d5 fix: stop player while detached
closes #51
2025-06-03 15:24:27 +01:00
7 changed files with 144 additions and 43 deletions

View File

@ -1,6 +1,9 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:ndk/shared/nips/nip19/nip19.dart';
import 'package:zap_stream_flutter/const.dart'; import 'package:zap_stream_flutter/const.dart';
import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/i18n/strings.g.dart';
import 'package:zap_stream_flutter/pages/category.dart'; import 'package:zap_stream_flutter/pages/category.dart';
@ -19,6 +22,26 @@ import 'package:zap_stream_flutter/theme.dart';
import 'package:zap_stream_flutter/utils.dart'; import 'package:zap_stream_flutter/utils.dart';
import 'package:zap_stream_flutter/widgets/header.dart'; import 'package:zap_stream_flutter/widgets/header.dart';
/// Resolves a NIP-05 identifier to a pubkey
Future<String?> resolveNip05(String handle, String domain) async {
try {
final url = "https://$domain/.well-known/nostr.json?name=$handle";
final response = await http.get(Uri.parse(url));
if (response.statusCode == 200) {
final json = jsonDecode(response.body);
final names = json["names"] as Map<String, dynamic>?;
if (names != null) {
return names[handle] as String?;
}
}
} catch (e) {
// NIP-05 resolution failed
}
return null;
}
void runZapStream() { void runZapStream() {
runApp( runApp(
MaterialApp.router( MaterialApp.router(
@ -142,7 +165,7 @@ void runZapStream() {
), ),
GoRoute( GoRoute(
path: "/:id", path: "/:id",
redirect: (context, state) { redirect: (context, state) async {
final id = state.pathParameters["id"]!; final id = state.pathParameters["id"]!;
if (id.startsWith("naddr1") || if (id.startsWith("naddr1") ||
id.startsWith("nevent1") || id.startsWith("nevent1") ||
@ -151,6 +174,30 @@ void runZapStream() {
} else if (id.startsWith("npub1") || } else if (id.startsWith("npub1") ||
id.startsWith("nprofile1")) { id.startsWith("nprofile1")) {
return "/p/$id"; return "/p/$id";
} else {
// Handle short URL format (handle@domain or just handle)
try {
final handleParts = id.contains("@")
? id.split("@")
: [id, "zap.stream"];
if (handleParts.length == 2) {
final handle = handleParts[0];
final domain = handleParts[1];
// Try to resolve NIP-05
final hexPubkey = await resolveNip05(handle, domain);
if (hexPubkey != null) {
// Check if they have a current live stream
// For now, redirect to profile - we could enhance this later
// to check for active streams and redirect to /e/{stream_id} instead
final npub = Nip19.encodePubKey(hexPubkey);
return "/p/$npub";
}
}
} catch (e) {
// If NIP-05 resolution fails, continue to show 404 or fallback
}
} }
return null; return null;
}, },

View File

@ -139,17 +139,13 @@ class _LivePage extends State<LivePage>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mq = MediaQuery.of(context); final mq = MediaQuery.of(context);
return PopScope( return PopScope(
canPop: false, canPop: !_streaming,
onPopInvokedWithResult: (didPop, result) async { onPopInvokedWithResult: (didPop, result) async {
if (_streaming) { if (_streaming && !didPop) {
final go = await showExitStreamDialog(context); final go = await showExitStreamDialog(context);
if (context.mounted) { if (context.mounted && go == true) {
if (go == true) { context.go("/");
context.go("/");
}
} }
} else {
context.go("/");
} }
}, },
child: ValueListenableBuilder( child: ValueListenableBuilder(

View File

@ -57,7 +57,7 @@ class StreamPage extends StatefulWidget {
class _StreamPage extends State<StreamPage> with RouteAware { class _StreamPage extends State<StreamPage> with RouteAware {
bool _offScreen = false; bool _offScreen = false;
StreamEvent? _stream; final GlobalKey _playerKey = GlobalKey();
bool isWidgetVisible(BuildContext context) { bool isWidgetVisible(BuildContext context) {
final router = GoRouter.of(context); final router = GoRouter.of(context);
@ -132,11 +132,11 @@ class _StreamPage extends State<StreamPage> with RouteAware {
final streamWidget = _buildPlayer(ctx, stream); final streamWidget = _buildPlayer(ctx, stream);
return ValueListenableBuilder( return ValueListenableBuilder(
valueListenable: mainPlayer.state, valueListenable: mainPlayer.state,
builder: (context, state, player) { builder: (context, playerState, child) {
if (state?.isPortrait == true) { if (playerState?.isPortrait == true) {
return _buildPortraitStream(context, stream, player!); return _buildPortraitStream(context, stream, child!);
} }
return _buildLandscapeStream(context, stream, player!); return _buildLandscapeStream(context, stream, child!);
}, },
child: streamWidget, child: streamWidget,
); );
@ -147,6 +147,7 @@ class _StreamPage extends State<StreamPage> with RouteAware {
Widget _buildPlayer(BuildContext context, StreamEvent stream) { Widget _buildPlayer(BuildContext context, StreamEvent stream) {
return (stream.info.stream != null && !_offScreen) return (stream.info.stream != null && !_offScreen)
? MainVideoPlayerWidget( ? MainVideoPlayerWidget(
key: _playerKey,
url: stream.info.stream!, url: stream.info.stream!,
placeholder: stream.info.image, placeholder: stream.info.image,
isLive: true, isLive: true,
@ -170,21 +171,20 @@ class _StreamPage extends State<StreamPage> with RouteAware {
return Stack( return Stack(
children: [ children: [
child,
Positioned(child: child), Positioned(child: child),
Positioned( Positioned(
child: Container( child: Container(
padding: EdgeInsets.symmetric(horizontal: 5), padding: EdgeInsets.symmetric(horizontal: 5, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.bottomCenter, begin: Alignment.topCenter,
end: Alignment.topCenter, end: Alignment.bottomCenter,
colors: [ colors: [
LAYER_0.withAlpha(50),
LAYER_0.withAlpha(200),
LAYER_0.withAlpha(255), LAYER_0.withAlpha(255),
LAYER_0.withAlpha(200),
LAYER_0.withAlpha(10),
], ],
stops: [0.0, 0.2, 1.0], stops: [0.0, 0.7, 1.0],
), ),
), ),
child: Column( child: Column(
@ -200,20 +200,31 @@ class _StreamPage extends State<StreamPage> with RouteAware {
width: mq.size.width, width: mq.size.width,
height: mq.size.height * 0.4, height: mq.size.height * 0.4,
padding: EdgeInsets.symmetric(horizontal: 10), padding: EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
LAYER_0.withAlpha(200),
LAYER_0.withAlpha(150),
LAYER_0.withAlpha(0),
],
stops: [0.0, 0.8, 1.0],
),
),
child: ShaderMask( 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, blendMode: BlendMode.dstIn,
shaderCallback:
(rect) => 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(rect),
child: ChatWidget( child: ChatWidget(
stream: stream, stream: stream,
showGoals: false, showGoals: false,

View File

@ -28,10 +28,44 @@ class PlayerState {
} }
class MainPlayer extends BaseAudioHandler { class MainPlayer extends BaseAudioHandler {
String? _url;
VideoPlayerController? _controller; VideoPlayerController? _controller;
ChewieController? _chewieController; ChewieController? _chewieController;
ValueNotifier<PlayerState?> state = ValueNotifier(null); ValueNotifier<PlayerState?> state = ValueNotifier(null);
MainPlayer() {
AppLifecycleListener(onStateChange: _onStateChanged);
}
void _onStateChanged(AppLifecycleState state) async {
developer.log(state.name);
switch (state) {
case AppLifecycleState.detached:
{
await dispose();
break;
}
case AppLifecycleState.resumed:
{
if (_controller == null && _url != null) {
await loadUrl(_url!);
}
break;
}
default:
{}
}
}
Future<void> dispose() async {
await super.stop();
await _controller?.dispose();
_chewieController?.dispose();
_controller = null;
_chewieController = null;
state.value = null;
}
ChewieController? get chewie { ChewieController? get chewie {
return _chewieController; return _chewieController;
} }
@ -48,7 +82,7 @@ class MainPlayer extends BaseAudioHandler {
@override @override
Future<void> stop() async { Future<void> stop() async {
await _chewieController?.pause(); await dispose();
} }
Future<void> loadUrl( Future<void> loadUrl(
@ -92,6 +126,7 @@ class MainPlayer extends BaseAudioHandler {
: null, : null,
); );
// insert media item // insert media item
mediaItem.add( mediaItem.add(
MediaItem( MediaItem(
@ -105,6 +140,9 @@ class MainPlayer extends BaseAudioHandler {
: null, : null,
), ),
); );
// Update player state immediately after initialization
updatePlayerState();
_url = url;
} catch (e) { } catch (e) {
if (e is PlatformException && e.code == "VideoError") { if (e is PlatformException && e.code == "VideoError") {
state.value = PlayerState( state.value = PlayerState(
@ -133,6 +171,7 @@ class MainPlayer extends BaseAudioHandler {
MediaControl.stop, MediaControl.stop,
], ],
playing: isPlaying, playing: isPlaying,
androidCompactActionIndices: [1],
processingState: switch (_chewieController processingState: switch (_chewieController
?.videoPlayerController ?.videoPlayerController
.value .value
@ -143,10 +182,13 @@ class MainPlayer extends BaseAudioHandler {
}, },
), ),
); );
state.value = PlayerState(
width: _controller!.value.size.width.floor(), if (_controller?.value.isInitialized == true && _controller!.value.size != Size.zero) {
height: _controller!.value.size.height.floor(), state.value = PlayerState(
isPlaying: isPlaying, width: _controller!.value.size.width.floor(),
); height: _controller!.value.size.height.floor(),
isPlaying: isPlaying,
);
}
} }
} }

View File

@ -233,7 +233,7 @@ StreamInfo extractStreamInfo(Nip01Event ev) {
String getHost(Nip01Event ev) { String getHost(Nip01Event ev) {
return ev.tags.firstWhere( return ev.tags.firstWhere(
(t) => t[0] == "p" && t.length > 2 && t[3] == "host", (t) => t[0] == "p" && t.length > 3 && t[3] == "host",
orElse: () => ["p", ev.pubKey], // fake p tag with event pubkey orElse: () => ["p", ev.pubKey], // fake p tag with event pubkey
)[1]; )[1];
} }

View File

@ -33,11 +33,15 @@ class __ChatRaidMessage extends State<ChatRaidMessage>
_from = _from =
widget.event.tags.firstWhereOrNull( widget.event.tags.firstWhereOrNull(
(t) => t[0] == "a" && (t[3] == "from" || t[3] == "root"), (t) =>
t[0] == "a" && t.length > 3 && (t[3] == "from" || t[3] == "root"),
)?[1]; )?[1];
_to = _to =
widget.event.tags.firstWhereOrNull( widget.event.tags.firstWhereOrNull(
(t) => t[0] == "a" && (t[3] == "to" || t[3] == "mention"), (t) =>
t[0] == "a" &&
t.length > 3 &&
(t[3] == "to" || t[3] == "mention"),
)?[1]; )?[1];
_isRaiding = _from == widget.stream.aTag; _isRaiding = _from == widget.stream.aTag;
final isAutoRaid = final isAutoRaid =

View File

@ -1,10 +1,11 @@
name: zap_stream_flutter name: zap_stream_flutter
description: "zap.stream" description: "zap.stream"
publish_to: "none" publish_to: "none"
version: 1.1.1+15 version: 1.1.4+18
environment: environment:
sdk: ^3.7.2 sdk: ^3.7.2
flutter: ^3.32.0
dependencies: dependencies:
flutter: flutter: