feat: introduce server with API, HLS streaming, Xtream proxy, and player UI

This commit is contained in:
Michael committed 2025-12-14 22:32:15 +01:00
1 parent 1d6fbfbe0e
commit e83fa445b1
3 files changed
+147 -27

No files matched your search

+144 -7
View File
@@ -163,13 +163,17 @@ void main(List<String> args) async {
});
}
/// Create API handler
/// Create API handler
Handler _createApiHandler(Router apiRouter) {
return (Request request) async {
final path = request.url.path;
// Only handle /api/* requests (excluding /api/xtream)
if (path.startsWith('api/') && !path.startsWith('api/xtream/')) {
// Only handle /api/* requests (excluding special handlers)
if (path.startsWith('api/') &&
!path.startsWith('api/xtream/') &&
!path.startsWith('api/stream/') &&
!path.startsWith('api/hls/')) {
return apiRouter(request);
}
@@ -243,18 +247,29 @@ Handler _createXtreamProxyHandler() {
// Check if this is a video file that needs streaming
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('.avi') ||
lowerPath.endsWith('.ts') ||
lowerPath.endsWith('.m4v') ||
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
if (isVideoFile) {
// For LIVE streams, use infinite streaming handler
if (isLiveStream) {
return _streamLiveChannel(targetUrl);
}
// For VOD files, use streaming with Range support for seeking
if (isVodFile) {
final rangeHeader = request.headers['range'];
return _streamVideoFile(targetUrl, rangeHeader);
}
@@ -454,6 +469,128 @@ Future<Response> _streamVideoFile(Uri targetUrl, String? rangeHeader) async {
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
///
/// Handles both relative and absolute URLs in HLS playlists
+1 -18
View File
@@ -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),
Expanded(
child: Text(
@@ -364,7 +347,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
if (widget.channels != null && widget.channels!.length > 1)
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
bottom: 100,
bottom: 160,
right: _showControls ? 24 : -80,
child: PointerInterceptor(
child: Column(
+2 -2
View File
@@ -85,8 +85,8 @@
<body>
<div id="player-container">
<!-- Native HTML5 controls enabled with 'controls' attribute -->
<video id="video" autoplay playsinline controls></video>
<!-- No native controls - Flutter overlay handles controls -->
<video id="video" autoplay playsinline></video>
<div id="loading">
<div class="spinner"></div>
<div id="loading-text">Loading stream...</div>