mirror of
https://github.com/R0m1k3/xtremobile.git
synced 2026-10-11 17:29:26 +02:00
Merge pull request #78 from R0m1k3/antigravity
feat: Add mobile settings tab UI with user profile, appearance, and s…
This commit is contained in:
commit
83fffb7ddb
4 files changed
+281
-12
No files matched your search
@@ -429,6 +429,7 @@ Handler _createStreamHandler() {
|
|||||||
// Get parameters
|
// Get parameters
|
||||||
final iptvUrl = request.url.queryParameters['url'];
|
final iptvUrl = request.url.queryParameters['url'];
|
||||||
final quality = request.url.queryParameters['quality'] ?? 'high';
|
final quality = request.url.queryParameters['quality'] ?? 'high';
|
||||||
|
final start = request.url.queryParameters['start']; // Start time in seconds
|
||||||
|
|
||||||
if (iptvUrl == null || iptvUrl.isEmpty) {
|
if (iptvUrl == null || iptvUrl.isEmpty) {
|
||||||
return Response.badRequest(body: 'Missing url parameter');
|
return Response.badRequest(body: 'Missing url parameter');
|
||||||
@@ -465,6 +466,15 @@ Handler _createStreamHandler() {
|
|||||||
'-f', 'mpegts',
|
'-f', 'mpegts',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Add seeking support if start time is provided
|
||||||
|
if (start != null && start.isNotEmpty) {
|
||||||
|
// Insert -ss before -i for faster seeking (input seeking)
|
||||||
|
final inputIndex = ffmpegArgs.indexOf('-i');
|
||||||
|
if (inputIndex != -1) {
|
||||||
|
ffmpegArgs.insertAll(inputIndex, ['-ss', start]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Video: Transcode based on quality
|
// Video: Transcode based on quality
|
||||||
// High = Copy (efficient), Low/Medium = Transcode to H.264 (compatible)
|
// High = Copy (efficient), Low/Medium = Transcode to H.264 (compatible)
|
||||||
if (quality == 'high') {
|
if (quality == 'high') {
|
||||||
|
|||||||
@@ -58,7 +58,10 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
bool _showControls = false;
|
bool _showControls = false;
|
||||||
bool _isPlaying = true;
|
bool _isPlaying = true;
|
||||||
double _currentPosition = 0;
|
double _currentPosition = 0;
|
||||||
|
bool _isPlaying = true;
|
||||||
|
double _currentPosition = 0;
|
||||||
double _totalDuration = 1;
|
double _totalDuration = 1;
|
||||||
|
double _volume = 1.0;
|
||||||
Timer? _controlsTimer;
|
Timer? _controlsTimer;
|
||||||
StreamSubscription? _messageSubscription;
|
StreamSubscription? _messageSubscription;
|
||||||
|
|
||||||
@@ -172,7 +175,42 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_currentPosition = value;
|
_currentPosition = value;
|
||||||
});
|
});
|
||||||
_sendMessage({'type': 'seek', 'value': value});
|
|
||||||
|
// Check if we are using server-side transcoding for VOD/Series
|
||||||
|
// If so, we must reload the player because we can't seek in a piped stream directly via JS
|
||||||
|
final settings = ref.read(iptvSettingsProvider);
|
||||||
|
final isTranscoding = widget.streamType != StreamType.live && settings.streamQuality != StreamQuality.high;
|
||||||
|
|
||||||
|
if (isTranscoding) {
|
||||||
|
debugPrint('Seeking in transcoded VOD - reloading player at ${value.round()}s');
|
||||||
|
// Reload player with new start time
|
||||||
|
// This will trigger _initializePlayer with the current position effectively
|
||||||
|
// But _initializePlayer reads from _currentPosition? No, it reads from provider OR starts at 0.
|
||||||
|
// We need to force it to start at 'value'.
|
||||||
|
// Best way is to just call _initializePlayer which builds the URL.
|
||||||
|
// But _initializePlayer checks logic.
|
||||||
|
|
||||||
|
// Actually _initializePlayer uses: startTime = positions.getPosition(_contentId);
|
||||||
|
// So if we save the position first, it might pick it up?
|
||||||
|
// Let's pass the seek time explicitly to a reload method or updating state.
|
||||||
|
|
||||||
|
// Simplest: Update the provider, then re-init.
|
||||||
|
ref.read(playbackPositionsProvider.notifier).savePosition(_contentId, value, _totalDuration);
|
||||||
|
_initializePlayer(startTimeOverride: value);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Direct play or Live - standard seeking
|
||||||
|
_sendMessage({'type': 'seek', 'value': value});
|
||||||
|
}
|
||||||
|
|
||||||
|
_onHover();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setVolume(double value) {
|
||||||
|
setState(() {
|
||||||
|
_volume = value;
|
||||||
|
});
|
||||||
|
_sendMessage({'type': 'set_volume', 'value': value});
|
||||||
_onHover();
|
_onHover();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +237,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
Future.microtask(() => _initializePlayer());
|
Future.microtask(() => _initializePlayer());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializePlayer() async {
|
Future<void> _initializePlayer({double? startTimeOverride}) async {
|
||||||
try {
|
try {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -265,12 +303,55 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
hlsUrl = streamEndpoint;
|
hlsUrl = streamEndpoint;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// For VOD and Series - use direct proxy streaming with Range support
|
// For VOD and Series
|
||||||
final streamUrl = widget.streamType == StreamType.vod
|
|
||||||
? xtreamService.getVodStreamUrl(currentStreamId, widget.containerExtension)
|
// CHECK QUALITY SETTING
|
||||||
: xtreamService.getSeriesStreamUrl(currentStreamId, widget.containerExtension);
|
// If Quality is NOT High, we force transcoding to ensure audio compatibility (AAC)
|
||||||
hlsUrl = streamUrl;
|
// This solves "No Sound" issues with AC3/DTS on mobile/web.
|
||||||
debugPrint('PlayerScreen: Direct playback URL: $hlsUrl');
|
final settings = ref.read(iptvSettingsProvider);
|
||||||
|
final useTranscoding = settings.streamQuality != StreamQuality.high;
|
||||||
|
|
||||||
|
if (useTranscoding) {
|
||||||
|
setState(() {
|
||||||
|
_statusMessage = 'Starting VOD Transcoding (Audio Fix)...';
|
||||||
|
});
|
||||||
|
|
||||||
|
final baseUrl = html.window.location.origin;
|
||||||
|
final vodStreamUrl = widget.streamType == StreamType.vod
|
||||||
|
? xtreamService.getVodStreamUrl(currentStreamId, widget.containerExtension)
|
||||||
|
: xtreamService.getSeriesStreamUrl(currentStreamId, widget.containerExtension);
|
||||||
|
|
||||||
|
final encodedUrl = Uri.encodeComponent(vodStreamUrl);
|
||||||
|
final qualityParam = switch (settings.streamQuality) {
|
||||||
|
StreamQuality.low => 'low',
|
||||||
|
StreamQuality.medium => 'medium',
|
||||||
|
StreamQuality.high => 'high',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate start time for seeking support
|
||||||
|
// If override provided (seeking), use it. Else check provider resume.
|
||||||
|
double startSeconds = startTimeOverride ?? 0;
|
||||||
|
if (startTimeOverride == null && _contentId.isNotEmpty) {
|
||||||
|
final positions = ref.read(playbackPositionsProvider);
|
||||||
|
final saved = positions.getPosition(_contentId);
|
||||||
|
if (saved > 0) startSeconds = saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct URL with start param
|
||||||
|
// Note: We force extension to .ts for mpegts.js compatibility
|
||||||
|
final streamEndpoint = '$baseUrl/api/stream/$currentStreamId?url=$encodedUrl&quality=$qualityParam&start=${startSeconds.round()}&ext=.ts';
|
||||||
|
|
||||||
|
debugPrint('PlayerScreen: Streaming Transcoded VOD: $streamEndpoint');
|
||||||
|
hlsUrl = streamEndpoint;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Direct Play (High Quality) - Original behavior
|
||||||
|
final streamUrl = widget.streamType == StreamType.vod
|
||||||
|
? xtreamService.getVodStreamUrl(currentStreamId, widget.containerExtension)
|
||||||
|
: xtreamService.getSeriesStreamUrl(currentStreamId, widget.containerExtension);
|
||||||
|
hlsUrl = streamUrl;
|
||||||
|
debugPrint('PlayerScreen: Direct playback URL (High Quality): $hlsUrl');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -278,8 +359,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get resume position for VOD/Series
|
// Get resume position for VOD/Series
|
||||||
double startTime = 0;
|
double startTime = startTimeOverride ?? 0;
|
||||||
if (widget.streamType != StreamType.live && _contentId.isNotEmpty) {
|
if (startTime <= 0 && widget.streamType != StreamType.live && _contentId.isNotEmpty) {
|
||||||
final positions = ref.read(playbackPositionsProvider);
|
final positions = ref.read(playbackPositionsProvider);
|
||||||
startTime = positions.getPosition(_contentId);
|
startTime = positions.getPosition(_contentId);
|
||||||
if (startTime > 0) {
|
if (startTime > 0) {
|
||||||
@@ -287,9 +368,19 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the HTML view with an iframe pointing to our player.html
|
// If we are Transcoding VOD, the start time is handled by the server (burned into stream)
|
||||||
|
// So we should NOT pass startTime to player.html, otherwise it might try to seek
|
||||||
|
// on a stream that already starts at X.
|
||||||
|
// EXCEPTION: If player.html receives an MPEG-TS stream that has timestamps starting at X,
|
||||||
|
// setting currentTime might be redundant or needed depending on player impl.
|
||||||
|
// Safest: If Transcoding, don't pass startTime to player.html (server handles it).
|
||||||
|
// If Direct Play, pass startTime.
|
||||||
|
|
||||||
|
final settings = ref.read(iptvSettingsProvider);
|
||||||
|
final isTranscoding = widget.streamType != StreamType.live && settings.streamQuality != StreamQuality.high;
|
||||||
|
|
||||||
final encodedHlsUrl = Uri.encodeComponent(hlsUrl);
|
final encodedHlsUrl = Uri.encodeComponent(hlsUrl);
|
||||||
final playerUrl = startTime > 0
|
final playerUrl = (!isTranscoding && startTime > 0)
|
||||||
? 'player.html?url=$encodedHlsUrl&startTime=${startTime.toStringAsFixed(0)}'
|
? 'player.html?url=$encodedHlsUrl&startTime=${startTime.toStringAsFixed(0)}'
|
||||||
: 'player.html?url=$encodedHlsUrl';
|
: 'player.html?url=$encodedHlsUrl';
|
||||||
|
|
||||||
@@ -618,6 +709,50 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
|
|
||||||
// Hover Controls Layer
|
// Hover Controls Layer
|
||||||
if (_showControls) ...[
|
if (_showControls) ...[
|
||||||
|
// Volume Slider (Left Side)
|
||||||
|
Positioned(
|
||||||
|
left: 24,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
height: 200,
|
||||||
|
width: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black45,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.volume_up, color: Colors.white, size: 20),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: RotatedBox(
|
||||||
|
quarterTurns: 3,
|
||||||
|
child: SliderTheme(
|
||||||
|
data: SliderThemeData(
|
||||||
|
trackHeight: 4,
|
||||||
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||||
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
|
||||||
|
activeTrackColor: Colors.white,
|
||||||
|
inactiveTrackColor: Colors.white24,
|
||||||
|
thumbColor: AppColors.primary,
|
||||||
|
overlayColor: AppColors.primary.withOpacity(0.2),
|
||||||
|
),
|
||||||
|
child: Slider(
|
||||||
|
value: _volume,
|
||||||
|
onChanged: _setVolume,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Play/Pause Button (Centered)
|
// Play/Pause Button (Centered)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|||||||
@@ -137,6 +137,69 @@ class _MobileSettingsTabState extends ConsumerState<MobileSettingsTab> {
|
|||||||
onChanged: (_) => themeNotifier.toggleTheme(),
|
onChanged: (_) => themeNotifier.toggleTheme(),
|
||||||
activeColor: AppColors.primary,
|
activeColor: AppColors.primary,
|
||||||
),
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Show Clock', style: TextStyle(color: AppColors.textPrimary)),
|
||||||
|
secondary: const Icon(Icons.access_time, color: AppColors.primary),
|
||||||
|
value: settings.showClock,
|
||||||
|
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setShowClock(val),
|
||||||
|
activeColor: AppColors.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Streaming Settings
|
||||||
|
_SectionHeader(title: 'Streaming'),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_DropdownSetting<StreamQuality>(
|
||||||
|
label: 'Quality',
|
||||||
|
icon: Icons.high_quality,
|
||||||
|
value: settings.streamQuality,
|
||||||
|
items: StreamQuality.values,
|
||||||
|
labelBuilder: (v) => v.name.toUpperCase(),
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) ref.read(iptvSettingsProvider.notifier).setStreamQuality(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_DropdownSetting<BufferSize>(
|
||||||
|
label: 'Buffer Size',
|
||||||
|
icon: Icons.memory,
|
||||||
|
value: settings.bufferSize,
|
||||||
|
items: BufferSize.values,
|
||||||
|
labelBuilder: (v) => v.name.toUpperCase(),
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) ref.read(iptvSettingsProvider.notifier).setBufferSize(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
_DropdownSetting<ConnectionTimeout>(
|
||||||
|
label: 'Timeout',
|
||||||
|
icon: Icons.timer,
|
||||||
|
value: settings.connectionTimeout,
|
||||||
|
items: ConnectionTimeout.values,
|
||||||
|
labelBuilder: (v) => '${v.name.toUpperCase()} (${ref.read(iptvSettingsProvider).timeoutSeconds}s)',
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) ref.read(iptvSettingsProvider.notifier).setConnectionTimeout(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Auto Reconnect', style: TextStyle(color: AppColors.textPrimary)),
|
||||||
|
secondary: const Icon(Icons.refresh, color: AppColors.primary),
|
||||||
|
value: settings.autoReconnect,
|
||||||
|
onChanged: (val) => ref.read(iptvSettingsProvider.notifier).setAutoReconnect(val),
|
||||||
|
activeColor: AppColors.primary,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -316,3 +379,57 @@ class _SettingsButton extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DropdownSetting<T> extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final T value;
|
||||||
|
final List<T> items;
|
||||||
|
final String Function(T) labelBuilder;
|
||||||
|
final ValueChanged<T?> onChanged;
|
||||||
|
|
||||||
|
const _DropdownSetting({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.value,
|
||||||
|
required this.items,
|
||||||
|
required this.labelBuilder,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: AppColors.primary),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DropdownButton<T>(
|
||||||
|
value: value,
|
||||||
|
underline: const SizedBox(),
|
||||||
|
dropdownColor: AppColors.surfaceVariant,
|
||||||
|
icon: const Icon(Icons.arrow_drop_down, color: AppColors.textSecondary),
|
||||||
|
style: const TextStyle(color: AppColors.textPrimary),
|
||||||
|
items: items.map((item) {
|
||||||
|
return DropdownMenuItem<T>(
|
||||||
|
value: item,
|
||||||
|
child: Text(labelBuilder(item)),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: onChanged,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -198,6 +198,13 @@
|
|||||||
if (isFinite(data.value)) {
|
if (isFinite(data.value)) {
|
||||||
video.currentTime = data.value;
|
video.currentTime = data.value;
|
||||||
}
|
}
|
||||||
|
} else if (data.type === 'set_volume') {
|
||||||
|
// value is 0.0 to 1.0
|
||||||
|
log('Setting volume: ' + data.value);
|
||||||
|
if (isFinite(data.value)) {
|
||||||
|
video.volume = Math.max(0, Math.min(1, data.value));
|
||||||
|
video.muted = (data.value === 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user