mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-20 21:48:06 +02:00
feat: introduce server with API, HLS streaming, Xtream proxy, and player UI
This commit is contained in:
1 parent
d46714c9bc
commit
2ca8f5e969
3 files changed
+147
-27
No files matched your search
+144
-7
@@ -163,13 +163,17 @@ void main(List<String> args) async {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create API handler
|
||||||
/// Create API handler
|
/// Create API handler
|
||||||
Handler _createApiHandler(Router apiRouter) {
|
Handler _createApiHandler(Router apiRouter) {
|
||||||
return (Request request) async {
|
return (Request request) async {
|
||||||
final path = request.url.path;
|
final path = request.url.path;
|
||||||
|
|
||||||
// Only handle /api/* requests (excluding /api/xtream)
|
// Only handle /api/* requests (excluding special handlers)
|
||||||
if (path.startsWith('api/') && !path.startsWith('api/xtream/')) {
|
if (path.startsWith('api/') &&
|
||||||
|
!path.startsWith('api/xtream/') &&
|
||||||
|
!path.startsWith('api/stream/') &&
|
||||||
|
!path.startsWith('api/hls/')) {
|
||||||
return apiRouter(request);
|
return apiRouter(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,18 +247,29 @@ Handler _createXtreamProxyHandler() {
|
|||||||
|
|
||||||
// Check if this is a video file that needs streaming
|
// Check if this is a video file that needs streaming
|
||||||
final lowerPath = targetUrl.path.toLowerCase();
|
final lowerPath = targetUrl.path.toLowerCase();
|
||||||
final isVideoFile = lowerPath.endsWith('.mp4') ||
|
|
||||||
|
// Detect LIVE TV streams (infinite streams)
|
||||||
|
final isLiveStream = lowerPath.contains('/live/') && lowerPath.endsWith('.ts');
|
||||||
|
|
||||||
|
// Detect VOD files (finite, seekable)
|
||||||
|
final isVodFile = !isLiveStream && (
|
||||||
|
lowerPath.endsWith('.mp4') ||
|
||||||
lowerPath.endsWith('.mkv') ||
|
lowerPath.endsWith('.mkv') ||
|
||||||
lowerPath.endsWith('.avi') ||
|
lowerPath.endsWith('.avi') ||
|
||||||
lowerPath.endsWith('.ts') ||
|
lowerPath.endsWith('.ts') ||
|
||||||
lowerPath.endsWith('.m4v') ||
|
lowerPath.endsWith('.m4v') ||
|
||||||
lowerPath.contains('/movie/') ||
|
lowerPath.contains('/movie/') ||
|
||||||
lowerPath.contains('/series/');
|
lowerPath.contains('/series/'));
|
||||||
|
|
||||||
print('Proxying request to: $targetUrl (video streaming: $isVideoFile)');
|
print('Proxying request to: $targetUrl (live: $isLiveStream, vod: $isVodFile)');
|
||||||
|
|
||||||
// For video files, use streaming with Range support for seeking
|
// For LIVE streams, use infinite streaming handler
|
||||||
if (isVideoFile) {
|
if (isLiveStream) {
|
||||||
|
return _streamLiveChannel(targetUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For VOD files, use streaming with Range support for seeking
|
||||||
|
if (isVodFile) {
|
||||||
final rangeHeader = request.headers['range'];
|
final rangeHeader = request.headers['range'];
|
||||||
return _streamVideoFile(targetUrl, rangeHeader);
|
return _streamVideoFile(targetUrl, rangeHeader);
|
||||||
}
|
}
|
||||||
@@ -454,6 +469,128 @@ Future<Response> _streamVideoFile(Uri targetUrl, String? rangeHeader) async {
|
|||||||
return Response.internalServerError(body: 'Too many redirects');
|
return Response.internalServerError(body: 'Too many redirects');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stream LIVE TV channel with persistent connection (no Content-Length)
|
||||||
|
///
|
||||||
|
/// Unlike VOD streaming, live streams are infinite and should not close
|
||||||
|
/// the connection until the client disconnects.
|
||||||
|
Future<Response> _streamLiveChannel(Uri targetUrl) async {
|
||||||
|
final maxRedirects = 5;
|
||||||
|
var currentUrl = targetUrl;
|
||||||
|
final cookies = <String, String>{};
|
||||||
|
|
||||||
|
print('Live Stream Request: $targetUrl');
|
||||||
|
|
||||||
|
for (var i = 0; i < maxRedirects; i++) {
|
||||||
|
try {
|
||||||
|
final client = HttpClient();
|
||||||
|
// CRITICAL: Very long timeouts for live streaming
|
||||||
|
client.connectionTimeout = const Duration(minutes: 2);
|
||||||
|
client.idleTimeout = const Duration(hours: 2); // Keep alive for 2 hours
|
||||||
|
|
||||||
|
final req = await client.getUrl(currentUrl);
|
||||||
|
req.followRedirects = false;
|
||||||
|
req.persistentConnection = true; // Keep connection alive
|
||||||
|
|
||||||
|
// IPTV-compatible headers
|
||||||
|
req.headers.set('User-Agent', 'VLC/3.0.18 LibVLC/3.0.18');
|
||||||
|
req.headers.set('Accept', '*/*');
|
||||||
|
req.headers.set('Connection', 'keep-alive');
|
||||||
|
req.headers.set('Accept-Encoding', 'identity');
|
||||||
|
req.headers.set('Icy-MetaData', '1'); // IPTV metadata support
|
||||||
|
|
||||||
|
// Apply cookies from redirects
|
||||||
|
if (cookies.isNotEmpty) {
|
||||||
|
final cookieHeader = cookies.entries.map((e) => '${e.key}=${e.value}').join('; ');
|
||||||
|
req.headers.set(HttpHeaders.cookieHeader, cookieHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.autoUncompress = false;
|
||||||
|
|
||||||
|
final response = await req.close();
|
||||||
|
print('Live Stream Response: ${response.statusCode} from $currentUrl');
|
||||||
|
|
||||||
|
// Collect cookies
|
||||||
|
response.cookies.forEach((cookie) {
|
||||||
|
cookies[cookie.name] = cookie.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle redirects
|
||||||
|
if (response.statusCode >= 300 && response.statusCode < 400) {
|
||||||
|
final location = response.headers.value(HttpHeaders.locationHeader);
|
||||||
|
if (location != null) {
|
||||||
|
print('Live stream redirecting to: $location');
|
||||||
|
currentUrl = Uri.parse(location);
|
||||||
|
await response.drain();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get content type
|
||||||
|
final contentType = response.headers.contentType?.mimeType ?? 'video/MP2T';
|
||||||
|
|
||||||
|
print('Live stream connected: $contentType');
|
||||||
|
|
||||||
|
// Use StreamController to pipe the live stream to the client
|
||||||
|
// This keeps the connection open and actively streams data
|
||||||
|
final controller = StreamController<List<int>>();
|
||||||
|
|
||||||
|
// Listen to the upstream response and forward data to the client
|
||||||
|
response.listen(
|
||||||
|
(data) {
|
||||||
|
if (!controller.isClosed) {
|
||||||
|
controller.add(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
print('Live stream error: $error');
|
||||||
|
if (!controller.isClosed) {
|
||||||
|
controller.addError(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
print('Live stream upstream ended');
|
||||||
|
if (!controller.isClosed) {
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelOnError: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// When client disconnects, close the controller
|
||||||
|
controller.onCancel = () {
|
||||||
|
print('Live stream client disconnected');
|
||||||
|
// Note: The response stream will be garbage collected
|
||||||
|
};
|
||||||
|
|
||||||
|
// Response headers for live streaming (NO Content-Length!)
|
||||||
|
final responseHeaders = <String, String>{
|
||||||
|
'content-type': contentType,
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
'cache-control': 'no-cache, no-store',
|
||||||
|
'connection': 'keep-alive',
|
||||||
|
// Don't set transfer-encoding or content-length for raw binary streams
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return streaming response
|
||||||
|
return Response.ok(
|
||||||
|
controller.stream,
|
||||||
|
headers: responseHeaders,
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
print('Live streaming exception: $e');
|
||||||
|
if (i == maxRedirects - 1) {
|
||||||
|
return Response.internalServerError(
|
||||||
|
body: jsonEncode({'error': 'Live streaming error', 'message': e.toString()}),
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.internalServerError(body: 'Too many redirects');
|
||||||
|
}
|
||||||
|
|
||||||
/// Rewrite URLs in M3U8 playlist to go through the proxy
|
/// Rewrite URLs in M3U8 playlist to go through the proxy
|
||||||
///
|
///
|
||||||
/// Handles both relative and absolute URLs in HLS playlists
|
/// Handles both relative and absolute URLs in HLS playlists
|
||||||
|
|||||||
@@ -323,23 +323,6 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
|
||||||
// Mute/Unmute Button
|
|
||||||
Material(
|
|
||||||
color: Colors.black54,
|
|
||||||
shape: const CircleBorder(),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: _toggleMute,
|
|
||||||
customBorder: const CircleBorder(),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Icon(
|
|
||||||
_isMuted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -364,7 +347,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
if (widget.channels != null && widget.channels!.length > 1)
|
if (widget.channels != null && widget.channels!.length > 1)
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
bottom: 100,
|
bottom: 160,
|
||||||
right: _showControls ? 24 : -80,
|
right: _showControls ? 24 : -80,
|
||||||
child: PointerInterceptor(
|
child: PointerInterceptor(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -85,8 +85,8 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="player-container">
|
<div id="player-container">
|
||||||
<!-- Native HTML5 controls enabled with 'controls' attribute -->
|
<!-- No native controls - Flutter overlay handles controls -->
|
||||||
<video id="video" autoplay playsinline controls></video>
|
<video id="video" autoplay playsinline></video>
|
||||||
<div id="loading">
|
<div id="loading">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<div id="loading-text">Loading stream...</div>
|
<div id="loading-text">Loading stream...</div>
|
||||||
|
|||||||
Reference in new issue
Block a user