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_localizations/flutter_localizations.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/i18n/strings.g.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/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() {
runApp(
MaterialApp.router(
@ -142,7 +165,7 @@ void runZapStream() {
),
GoRoute(
path: "/:id",
redirect: (context, state) {
redirect: (context, state) async {
final id = state.pathParameters["id"]!;
if (id.startsWith("naddr1") ||
id.startsWith("nevent1") ||
@ -151,6 +174,30 @@ void runZapStream() {
} else if (id.startsWith("npub1") ||
id.startsWith("nprofile1")) {
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;
},

View File

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

View File

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

View File

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

View File

@ -33,11 +33,15 @@ class __ChatRaidMessage extends State<ChatRaidMessage>
_from =
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];
_to =
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];
_isRaiding = _from == widget.stream.aTag;
final isAutoRaid =

View File

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