mirror of
https://github.com/R0m1k3/xtremflow.git
synced 2026-10-23 06:58:05 +02:00
feat: Implement TV recording management with API endpoints, a data model, and a scheduling service that uses FFmpeg to record streams.
This commit is contained in:
1 parent
33c6385d7e
commit
13647a35da
6 files changed
+70
-347
No files matched your search
+1
-1
@@ -17,7 +17,7 @@ ENV DART_VM_OPTIONS="--old_gen_heap_size=4096"
|
|||||||
RUN flutter config --enable-web
|
RUN flutter config --enable-web
|
||||||
|
|
||||||
# Force cache invalidation when source changes (update this value to force rebuild)
|
# Force cache invalidation when source changes (update this value to force rebuild)
|
||||||
ARG CACHEBUST=2026-03-09-epg-v1
|
ARG CACHEBUST=2026-03-09-mobile-v1
|
||||||
|
|
||||||
# Copy dependency files first for better caching
|
# Copy dependency files first for better caching
|
||||||
COPY pubspec.yaml ./
|
COPY pubspec.yaml ./
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class PlayerScreen extends ConsumerStatefulWidget {
|
|||||||
final PlaylistConfig playlist;
|
final PlaylistConfig playlist;
|
||||||
final StreamType streamType;
|
final StreamType streamType;
|
||||||
final String containerExtension;
|
final String containerExtension;
|
||||||
final List<Channel>? channels;
|
final List<dynamic>? channels;
|
||||||
final double? startTime;
|
final double? startTime;
|
||||||
final Duration? duration;
|
final Duration? duration;
|
||||||
|
|
||||||
@@ -57,6 +57,7 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
StreamSubscription? _messageSubscription;
|
StreamSubscription? _messageSubscription;
|
||||||
final String _aspectRatio = 'contain';
|
final String _aspectRatio = 'contain';
|
||||||
bool _isSeeking = false;
|
bool _isSeeking = false;
|
||||||
|
late final String _viewIdPrefix = DateTime.now().millisecondsSinceEpoch.toString();
|
||||||
String _viewId = 'iptv-player';
|
String _viewId = 'iptv-player';
|
||||||
String? _currentStreamUrl;
|
String? _currentStreamUrl;
|
||||||
String _statusMessage = 'Loading...';
|
String _statusMessage = 'Loading...';
|
||||||
@@ -68,8 +69,13 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.channels != null) {
|
if (widget.channels != null) {
|
||||||
_currentIndex =
|
_currentIndex = widget.channels!.indexWhere((c) {
|
||||||
widget.channels!.indexWhere((c) => c.streamId == widget.streamId);
|
try {
|
||||||
|
return (c.streamId ?? c.id ?? '').toString() == widget.streamId;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
if (_currentIndex == -1) _currentIndex = 0;
|
if (_currentIndex == -1) _currentIndex = 0;
|
||||||
}
|
}
|
||||||
// Show controls at start, then auto-hide after timeout
|
// Show controls at start, then auto-hide after timeout
|
||||||
@@ -97,12 +103,16 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine Stream ID (Initial or Current Index)
|
// Determine Stream ID (Initial or Current Index)
|
||||||
final currentStreamId = isChannelSwitch && widget.channels != null
|
String currentStreamId = widget.streamId;
|
||||||
? widget.channels![_currentIndex].streamId
|
if (isChannelSwitch && widget.channels != null) {
|
||||||
: widget.streamId;
|
try {
|
||||||
|
final item = widget.channels![_currentIndex];
|
||||||
|
currentStreamId = (item.streamId ?? item.id ?? '').toString();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
_viewId =
|
// Stable view ID for the instance to prevent iframe reload on rebuild (orientation change)
|
||||||
'iptv-player-$currentStreamId-${DateTime.now().millisecondsSinceEpoch}';
|
_viewId = 'iptv-player-$currentStreamId-$_viewIdPrefix';
|
||||||
|
|
||||||
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
final service = ref.read(xtreamServiceProvider(widget.playlist));
|
||||||
String streamUrl = '';
|
String streamUrl = '';
|
||||||
@@ -274,10 +284,19 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _toggleFullscreen() {
|
void _toggleFullscreen() {
|
||||||
if (html.document.fullscreenElement != null) {
|
try {
|
||||||
html.document.exitFullscreen();
|
if (html.document.fullscreenElement != null) {
|
||||||
} else {
|
html.document.exitFullscreen();
|
||||||
html.document.documentElement?.requestFullscreen();
|
} else {
|
||||||
|
final element = html.document.documentElement;
|
||||||
|
if (element != null) {
|
||||||
|
element.requestFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('[PlayerScreen] Fullscreen error: $e');
|
||||||
|
// Fallback: Message the player to try internal fullscreen
|
||||||
|
_sendMessage({'type': 'request_fullscreen'});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,9 +409,13 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
widget.channels != null
|
widget.channels != null
|
||||||
? widget
|
? (widget.channels![_currentIndex]
|
||||||
.channels![_currentIndex]
|
.name ??
|
||||||
.name
|
widget.channels![
|
||||||
|
_currentIndex]
|
||||||
|
.title ??
|
||||||
|
'Unknown')
|
||||||
|
.toString()
|
||||||
: widget.title,
|
: widget.title,
|
||||||
style: GoogleFonts.outfit(
|
style: GoogleFonts.outfit(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:video_player/video_player.dart';
|
|
||||||
import 'package:chewie/chewie.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'dart:async';
|
|
||||||
import '../../../core/theme/app_colors.dart';
|
|
||||||
import '../../../core/widgets/glass_container.dart';
|
|
||||||
|
|
||||||
class LitePlayerView extends ConsumerStatefulWidget {
|
|
||||||
final String streamUrl;
|
|
||||||
final bool isLive;
|
|
||||||
final VoidCallback? onNext;
|
|
||||||
final VoidCallback? onPrevious;
|
|
||||||
|
|
||||||
const LitePlayerView({
|
|
||||||
super.key,
|
|
||||||
required this.streamUrl,
|
|
||||||
required this.isLive,
|
|
||||||
this.onNext,
|
|
||||||
this.onPrevious,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<LitePlayerView> createState() => _LitePlayerViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LitePlayerViewState extends ConsumerState<LitePlayerView> {
|
|
||||||
late VideoPlayerController _videoController;
|
|
||||||
ChewieController? _chewieController;
|
|
||||||
bool _error = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_initializePlayer();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(LitePlayerView oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.streamUrl != widget.streamUrl) {
|
|
||||||
_disposeControllers();
|
|
||||||
_initializePlayer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initializePlayer() async {
|
|
||||||
try {
|
|
||||||
setState(() => _error = false);
|
|
||||||
_videoController =
|
|
||||||
VideoPlayerController.networkUrl(Uri.parse(widget.streamUrl));
|
|
||||||
await _videoController.initialize();
|
|
||||||
|
|
||||||
_chewieController = ChewieController(
|
|
||||||
videoPlayerController: _videoController,
|
|
||||||
autoPlay: true,
|
|
||||||
looping: false,
|
|
||||||
isLive: widget.isLive,
|
|
||||||
aspectRatio: _videoController.value.aspectRatio > 0
|
|
||||||
? _videoController.value.aspectRatio
|
|
||||||
: 16 / 9,
|
|
||||||
materialProgressColors: ChewieProgressColors(
|
|
||||||
playedColor: AppColors.primary,
|
|
||||||
handleColor: Colors.white,
|
|
||||||
backgroundColor: Colors.white24,
|
|
||||||
bufferedColor: Colors.white54,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Fullscreen configuration for Web/Mobile
|
|
||||||
allowFullScreen: true,
|
|
||||||
allowMuting: true,
|
|
||||||
showControls: true,
|
|
||||||
useRootNavigator: true, // Crucial for escaping layout constraints
|
|
||||||
|
|
||||||
// Orientation settings are often ignored or cause issues on web
|
|
||||||
// but we keep them for native mobile if needed.
|
|
||||||
// On web, the browser handles this.
|
|
||||||
deviceOrientationsOnEnterFullScreen: [
|
|
||||||
DeviceOrientation.landscapeLeft,
|
|
||||||
DeviceOrientation.landscapeRight,
|
|
||||||
],
|
|
||||||
deviceOrientationsAfterFullScreen: [
|
|
||||||
DeviceOrientation.portraitUp,
|
|
||||||
],
|
|
||||||
|
|
||||||
// Custom Controls to include Zapping
|
|
||||||
additionalOptions: (context) {
|
|
||||||
return <OptionItem>[
|
|
||||||
if (widget.onNext != null)
|
|
||||||
OptionItem(
|
|
||||||
onTap: (context) {
|
|
||||||
widget.onNext!();
|
|
||||||
Navigator.of(context).pop(); // Close options menu
|
|
||||||
},
|
|
||||||
iconData: Icons.skip_next,
|
|
||||||
title: 'Next Channel',
|
|
||||||
),
|
|
||||||
if (widget.onPrevious != null)
|
|
||||||
OptionItem(
|
|
||||||
onTap: (context) {
|
|
||||||
widget.onPrevious!();
|
|
||||||
Navigator.of(context).pop(); // Close options menu
|
|
||||||
},
|
|
||||||
iconData: Icons.skip_previous,
|
|
||||||
title: 'Previous Channel',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
_chewieController!.addListener(() {
|
|
||||||
if (mounted) setState(() {});
|
|
||||||
});
|
|
||||||
|
|
||||||
setState(() {});
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('Error initializing Lite Player: $e');
|
|
||||||
setState(() => _error = true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _disposeControllers() {
|
|
||||||
_videoController.dispose();
|
|
||||||
_chewieController?.dispose();
|
|
||||||
_chewieController = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_disposeControllers();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
if (_error) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.error_outline, color: Colors.red, size: 48),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text('Error loading video',
|
|
||||||
style: TextStyle(color: Colors.white)),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
_disposeControllers();
|
|
||||||
_initializePlayer();
|
|
||||||
},
|
|
||||||
child: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_chewieController != null &&
|
|
||||||
_chewieController!.videoPlayerController.value.isInitialized) {
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
Chewie(controller: _chewieController!),
|
|
||||||
|
|
||||||
// Right-Side Overlays (Zapping + Fullscreen)
|
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// Fullscreen Toggle
|
|
||||||
IconButton(
|
|
||||||
onPressed: () {
|
|
||||||
// For Web, ensure we use the built-in fullscreen transition
|
|
||||||
// which is more compatible with Safari/Chrome security
|
|
||||||
if (_chewieController?.isFullScreen == true) {
|
|
||||||
_chewieController?.exitFullScreen();
|
|
||||||
} else {
|
|
||||||
// On web, sometimes we need to toggle more than once
|
|
||||||
// to ensure the browser captures the intent
|
|
||||||
_chewieController?.enterFullScreen();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: Icon(
|
|
||||||
_chewieController?.isFullScreen == true
|
|
||||||
? Icons.fullscreen_exit
|
|
||||||
: Icons.fullscreen,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 32),
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: Colors.black87,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Zapping Controls
|
|
||||||
if (widget.onPrevious != null || widget.onNext != null)
|
|
||||||
GlassContainer(
|
|
||||||
borderRadius: 16,
|
|
||||||
opacity: 0.3,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (widget.onPrevious != null)
|
|
||||||
IconButton(
|
|
||||||
onPressed: widget.onPrevious,
|
|
||||||
icon: const Icon(Icons.keyboard_arrow_up,
|
|
||||||
color: Colors.white, size: 36),
|
|
||||||
tooltip: 'Previous Channel',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (widget.onNext != null)
|
|
||||||
IconButton(
|
|
||||||
onPressed: widget.onNext,
|
|
||||||
icon: const Icon(Icons.keyboard_arrow_down,
|
|
||||||
color: Colors.white, size: 36),
|
|
||||||
tooltip: 'Next Channel',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator(color: AppColors.primary));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../../../features/iptv/screens/player_screen.dart';
|
import '../../../../features/iptv/screens/player_screen.dart';
|
||||||
export '../../../../features/iptv/screens/player_screen.dart' show StreamType;
|
export '../../../../features/iptv/screens/player_screen.dart' show StreamType;
|
||||||
import '../../../../core/models/playlist_config.dart';
|
import '../../../../core/models/playlist_config.dart';
|
||||||
import '../../../../features/iptv/widgets/lite_player_view.dart';
|
|
||||||
import '../../../../features/iptv/providers/xtream_provider.dart';
|
|
||||||
|
|
||||||
class MobilePlayerScreen extends ConsumerWidget {
|
class MobilePlayerScreen extends ConsumerWidget {
|
||||||
final String streamId;
|
final String streamId;
|
||||||
@@ -12,7 +10,8 @@ class MobilePlayerScreen extends ConsumerWidget {
|
|||||||
final StreamType streamType;
|
final StreamType streamType;
|
||||||
final String containerExtension;
|
final String containerExtension;
|
||||||
final PlaylistConfig playlist;
|
final PlaylistConfig playlist;
|
||||||
final List<dynamic>? channels; // New: List of channels for zapping
|
final List<dynamic>? channels;
|
||||||
|
final double? startTime;
|
||||||
|
|
||||||
const MobilePlayerScreen({
|
const MobilePlayerScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -22,100 +21,22 @@ class MobilePlayerScreen extends ConsumerWidget {
|
|||||||
this.streamType = StreamType.live,
|
this.streamType = StreamType.live,
|
||||||
this.containerExtension = 'mp4',
|
this.containerExtension = 'mp4',
|
||||||
this.channels,
|
this.channels,
|
||||||
|
this.startTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final service = ref.read(xtreamServiceProvider(playlist));
|
// Use the premium PlayerScreen which handles IFrames and custom overlays
|
||||||
String streamUrl = '';
|
// This removes the redundant AppBar and fixes full-screen/controls issues
|
||||||
|
return PlayerScreen(
|
||||||
if (streamType == StreamType.live) {
|
key: ValueKey('player-$streamId'),
|
||||||
streamUrl = service.getLiveStreamUrl(streamId);
|
streamId: streamId,
|
||||||
} else if (streamType == StreamType.vod) {
|
title: title,
|
||||||
streamUrl = service.getVodStreamUrl(streamId, containerExtension);
|
playlist: playlist,
|
||||||
} else if (streamType == StreamType.series) {
|
streamType: streamType,
|
||||||
streamUrl = service.getSeriesStreamUrl(streamId, containerExtension);
|
containerExtension: containerExtension,
|
||||||
}
|
channels: channels,
|
||||||
|
startTime: startTime,
|
||||||
// Zapping logic
|
|
||||||
VoidCallback? onNext;
|
|
||||||
VoidCallback? onPrevious;
|
|
||||||
|
|
||||||
if (channels != null && channels!.isNotEmpty) {
|
|
||||||
final currentIndex = channels!.indexWhere((c) {
|
|
||||||
try {
|
|
||||||
// Supports Channel/Movie (.streamId) and Episode (.id)
|
|
||||||
final cId = (c.streamId ?? c.id).toString();
|
|
||||||
return cId == streamId;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentIndex != -1) {
|
|
||||||
void navigateTo(int index) {
|
|
||||||
final item = channels![index % channels!.length];
|
|
||||||
String nId = '';
|
|
||||||
String nTitle = '';
|
|
||||||
String nExt = containerExtension;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// ID Extraction
|
|
||||||
nId = (item.streamId ?? item.id).toString();
|
|
||||||
// Title Extraction (name for channels/movies, title for episodes)
|
|
||||||
nTitle = item.name ?? item.title ?? 'Unknown';
|
|
||||||
|
|
||||||
// Format series title predictably
|
|
||||||
if (streamType == StreamType.series && title.contains(' - ')) {
|
|
||||||
final prefix = title.split(' - ').first;
|
|
||||||
nTitle = '$prefix - Episode ${item.episodeNum ?? ""}';
|
|
||||||
}
|
|
||||||
|
|
||||||
nExt = item.containerExtension ?? containerExtension;
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
if (nId.isEmpty) return;
|
|
||||||
|
|
||||||
Navigator.pushReplacement(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => MobilePlayerScreen(
|
|
||||||
streamId: nId,
|
|
||||||
title: nTitle,
|
|
||||||
playlist: playlist,
|
|
||||||
streamType: streamType,
|
|
||||||
containerExtension: nExt,
|
|
||||||
channels: channels,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
onNext = () => navigateTo(currentIndex + 1);
|
|
||||||
onPrevious = () => navigateTo(currentIndex - 1 + channels!.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
appBar: AppBar(
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
elevation: 0,
|
|
||||||
title: Text(title,
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 16)),
|
|
||||||
leading: IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body: SizedBox.expand(
|
|
||||||
child: LitePlayerView(
|
|
||||||
streamUrl: streamUrl,
|
|
||||||
isLive: streamType == StreamType.live,
|
|
||||||
onNext: onNext,
|
|
||||||
onPrevious: onPrevious,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,6 +432,15 @@
|
|||||||
video.muted = vol === 0;
|
video.muted = vol === 0;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'request_fullscreen':
|
||||||
|
if (video.requestFullscreen) {
|
||||||
|
video.requestFullscreen();
|
||||||
|
} else if (video.webkitRequestFullscreen) {
|
||||||
|
video.webkitRequestFullscreen();
|
||||||
|
} else if (video.msRequestFullscreen) {
|
||||||
|
video.msRequestFullscreen();
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -499,6 +499,14 @@
|
|||||||
if (isFinite(video.duration)) {
|
if (isFinite(video.duration)) {
|
||||||
video.currentTime = parseFloat(data.value);
|
video.currentTime = parseFloat(data.value);
|
||||||
}
|
}
|
||||||
|
} else if (data.type === 'request_fullscreen') {
|
||||||
|
if (video.requestFullscreen) {
|
||||||
|
video.requestFullscreen();
|
||||||
|
} else if (video.webkitRequestFullscreen) {
|
||||||
|
video.webkitRequestFullscreen();
|
||||||
|
} else if (video.msRequestFullscreen) {
|
||||||
|
video.msRequestFullscreen();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user