6 Commits

Author SHA1 Message Date
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
5 changed files with 136 additions and 40 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

@ -1,7 +1,7 @@
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.3+17
environment: environment:
sdk: ^3.7.2 sdk: ^3.7.2