diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 61ef69d..015a2c3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -78,10 +78,16 @@ jobs: - name: Build working-directory: ${{github.workspace}} run: | - flutter build apk --target-platform=android-arm,android-arm64 + flutter build apk --target-platform=android-arm,android-arm64 --split-per-abi - - name: Upload artifact + - name: Upload artifact (arm8) uses: actions/upload-artifact@v4 with: - name: app-release.apk - path: ${{github.workspace}}/build/app/outputs/apk/release/app-release.apk + name: SyncTube-arm8.apk + path: ${{github.workspace}}/build/app/outputs/flutter-apk/app-arm64-v8a-release.apk + + - name: Upload artifact (arm7) + uses: actions/upload-artifact@v4 + with: + name: SyncTube-arm7.apk + path: ${{github.workspace}}/build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 7b721c0..5e97f6c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -45,10 +45,10 @@ android:name="flutterEmbedding" android:value="2" /> - + /> --> with WidgetsBindingObserver { super.initState(); app = AppModel(widget.url); fileUploader = FileUploader(widget.url); - Settings.applySettings(app); + Settings.load(app); WakelockPlus.enable(); WidgetsBinding.instance.addObserver(this); final nativeOrientation = NativeDeviceOrientationCommunicator(); @@ -124,33 +124,37 @@ class _AppState extends State with WidgetsBindingObserver { direction: orientation == Orientation.landscape ? Axis.horizontal : Axis.vertical, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Selector( selector: (context, app) => app.isChatVisible, builder: (context, isChatVisible, child) { return Selector( selector: (context, player) { - final isInit = - player.controller?.value.isInitialized ?? false; - if (!isInit) return 16 / 9; - return player.controller?.value.aspectRatio ?? 16 / 9; + if (!player.isVideoLoaded()) return 16 / 9; + final width = player.player.state.width; + final height = player.player.state.height; + if (width == null || height == null || height == 0) + return 16 / 9; + return width / height; }, builder: (context, ratio, child) { final media = MediaQuery.of(context); + final isLandscape = orientation == Orientation.landscape; return Container( color: Colors.black, padding: EdgeInsets.only( top: _isKeyboardVisible() ? media.padding.top : 0, ), - width: orientation == Orientation.landscape + width: isLandscape ? isChatVisible ? media.size.width / 1.5 : media.size.width : double.infinity, - height: playerHeight(ratio), + height: isLandscape ? null : playerHeight(ratio), child: Column( children: [ - if (orientation == Orientation.landscape && + if (isLandscape && !_isKeyboardVisible() && isChatVisible) ChatPanel(), @@ -230,12 +234,10 @@ class _AppState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { - case AppLifecycleState.paused: + case .paused: app.inBackground(); - break; - case AppLifecycleState.resumed: + case .resumed: app.inForeground(); - break; default: } } diff --git a/lib/chat.dart b/lib/chat.dart index 983da4e..ff85cd7 100644 --- a/lib/chat.dart +++ b/lib/chat.dart @@ -257,41 +257,14 @@ class _ChatState extends State { AnimatedOpacity( duration: const Duration(milliseconds: 100), opacity: showRewindMenu ? 1 : 0, - child: showRewindMenu - ? SizedBox( - height: 60, - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - mainAxisSize: MainAxisSize.min, - children: [ - for (final time in rewindOptions) - Expanded( - child: TextButton( - style: TextButton.styleFrom( - // primary: Theme.of(context).rewindButton, - padding: EdgeInsets.zero, - ), - child: Align( - alignment: Alignment.center, - child: Text(time.toString(), maxLines: 1), - ), - onPressed: () { - chat.sendMessage('/$time'); - setState(() => showRewindMenu = false); - }, - ), - ), - ], - ), - ) - : null, + child: showRewindMenu ? _buildRewindMenu(chat) : null, ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.max, children: [ + SizedBox(width: 10), Expanded( child: TextField( inputFormatters: [ @@ -308,6 +281,16 @@ class _ChatState extends State { contentPadding: const EdgeInsets.all(14), hintText: _hintText(chat), border: OutlineInputBorder( + borderSide: BorderSide( + width: 2, + ), + borderRadius: BorderRadius.circular(20), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + width: 2, + color: Theme.of(context).focusColor, + ), borderRadius: BorderRadius.circular(20), ), ), @@ -359,41 +342,9 @@ class _ChatState extends State { }, ), ), - if (!chat.isUnknownClient) - GestureDetector( - onLongPress: () { - setState(() => showRewindMenu = !showRewindMenu); - }, - child: IconButton( - padding: const EdgeInsets.only(left: 10, right: 20), - onPressed: () { - if (showRewindMenu) { - setState(() => showRewindMenu = false); - return; - } - setState(() { - showEmotesTab = !showEmotesTab; - if (showEmotesTab) { - reopenKeyboard = inputFocus.hasFocus; - inputFocus.unfocus(); - } else { - if (reopenKeyboard && textController.text.isNotEmpty) - inputFocus.requestFocus(); - reopenKeyboard = false; - } - }); - SystemChrome.restoreSystemUIOverlays(); - }, - // tooltip: 'Show emotes', - icon: Icon( - showRewindMenu ? Icons.close : Icons.mood, - size: 35, - color: showEmotesTab - ? Theme.of(context).colorScheme.primary - : Theme.of(context).icon, - ), - ), - ), + chat.isUnknownClient + ? SizedBox(width: 10) + : _buildEmotesButton(context), ], ), if (showEmotesTab) @@ -404,4 +355,73 @@ class _ChatState extends State { ], ); } + + SizedBox _buildRewindMenu(ChatModel chat) { + return SizedBox( + height: 60, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.min, + children: [ + for (final time in rewindOptions) + Expanded( + child: TextButton( + style: TextButton.styleFrom( + // primary: Theme.of(context).rewindButton, + padding: EdgeInsets.zero, + ), + child: Align( + alignment: Alignment.center, + child: Text(time.toString(), maxLines: 1), + ), + onPressed: () { + chat.sendMessage('/$time'); + setState(() => showRewindMenu = false); + }, + ), + ), + ], + ), + ); + } + + GestureDetector _buildEmotesButton(BuildContext context) { + return GestureDetector( + onLongPress: () { + HapticFeedback.mediumImpact(); + setState(() => showRewindMenu = !showRewindMenu); + }, + child: Padding( + padding: const EdgeInsets.all(5), + child: IconButton( + onPressed: () { + if (showRewindMenu) { + setState(() => showRewindMenu = false); + return; + } + setState(() { + showEmotesTab = !showEmotesTab; + if (showEmotesTab) { + reopenKeyboard = inputFocus.hasFocus; + inputFocus.unfocus(); + } else { + if (reopenKeyboard && textController.text.isNotEmpty) + inputFocus.requestFocus(); + reopenKeyboard = false; + } + }); + SystemChrome.restoreSystemUIOverlays(); + }, + icon: Icon( + showRewindMenu ? Icons.close : Icons.mood, + size: 35, + color: showEmotesTab + ? Theme.of(context).colorScheme.primary + : Theme.of(context).icon, + ), + ), + ), + ); + } } diff --git a/lib/chat_panel.dart b/lib/chat_panel.dart index ede7a76..1c71560 100644 --- a/lib/chat_panel.dart +++ b/lib/chat_panel.dart @@ -32,16 +32,10 @@ class ChatPanel extends StatelessWidget { ), ), const Spacer(flex: 2), - TextButton( - onPressed: () => showUsersSnackBar(context), - style: ButtonStyle( - padding: WidgetStateProperty.all( - EdgeInsets.symmetric(horizontal: 5, vertical: 5), - ), - ), + Flexible( + flex: 100, child: _onlineButton(panel, context), ), - const Spacer(flex: 100), leaderButton(panel, context, parentW), const Spacer(flex: 5), IconButton( @@ -119,23 +113,35 @@ class ChatPanel extends StatelessWidget { } Widget _onlineButton(ChatPanelModel panel, BuildContext context) { - final isPlayerIconVisible = !panel.serverPlay || panel.hasLeader(); - return Row( - children: [ - Text( - !panel.isConnected - ? 'Connection...' - : '${panel.clients.length} online', - style: TextStyle(color: Theme.of(context).icon), + final isPlayerIconVisible = panel.lastState.paused || panel.hasLeader(); + final clientsOnlineText = !panel.isConnected + ? 'Connection...' + : '${panel.clients.length} online'; + + return TextButton( + onPressed: () => showUsersSnackBar(context), + style: ButtonStyle( + padding: WidgetStateProperty.all( + EdgeInsets.symmetric(horizontal: 5, vertical: 5), ), - if (isPlayerIconVisible) - Icon( - panel.serverPlay ? Icons.play_arrow : Icons.pause, - color: Theme.of(context).icon, - ) - else - const Text(' '), - ], + ), + child: Row( + children: [ + Flexible( + child: Text( + clientsOnlineText, + style: TextStyle(color: Theme.of(context).icon), + overflow: .ellipsis, + maxLines: 1, + ), + ), + if (isPlayerIconVisible) + Icon( + panel.lastState.paused ? Icons.pause : Icons.play_arrow, + color: Theme.of(context).icon, + ), + ], + ), ); } } diff --git a/lib/main.dart b/lib/main.dart index a8c3a1d..af38e88 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,12 @@ import 'dart:convert'; +import 'dart:io'; import 'package:app_links/app_links.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; +import 'package:media_kit/media_kit.dart'; import 'package:ota_update/ota_update.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -14,6 +16,8 @@ import 'package:url_launcher/url_launcher_string.dart'; import 'app.dart'; void main() async { + WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); runApp(Main()); } @@ -63,7 +67,10 @@ class ServerListPage extends StatefulWidget { class _ServerListPageState extends State { final List items = []; Offset? _tapPosition; - final latestApkUrl = 'http://synctube.nya.pub/SyncTubeApp/SyncTube.apk'; + late String latestApkUrl; + final latestApkUrlArm7 = 'http://synctube.nya.pub/SyncTubeApp/SyncTube.apk'; + final latestApkUrlArm8 = + 'http://synctube.nya.pub/SyncTubeApp/SyncTube-arm8.apk'; final pubspecUrl = 'http://synctube.nya.pub/SyncTubeApp/pubspec.yaml'; @override @@ -75,9 +82,11 @@ class _ServerListPageState extends State { void init() async { Settings.isTV = await _checkTvMode(); + final isArm8 = await isArm64(); + latestApkUrl = isArm8 ? latestApkUrlArm8 : latestApkUrl; + final prefs = await SharedPreferencesAsync(); var strings = await prefs.getStringList('serverListItems') ?? []; - print(strings); if (strings.length == 0) { strings = ['Example', 'https://synctube.onrender.com/']; } @@ -343,6 +352,17 @@ class _ServerListPageState extends State { } } + Future isArm64() async { + final plugin = DeviceInfoPlugin(); + if (Platform.isAndroid) { + final info = await plugin.androidInfo; + final abis = info.supportedAbis; + return abis.contains('arm64-v8a'); + } + if (Platform.isIOS) return true; + return false; + } + void showAlert(String text) { showDialog( context: context, diff --git a/lib/models/app.dart b/lib/models/app.dart index 8416f9c..6653b1b 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/status.dart' as status; @@ -22,6 +22,20 @@ enum MainTab { settings, } +class LastState { + double time; + double rate; + bool paused; + bool pausedByServer; + + LastState({ + this.time = 0, + this.rate = 1.0, + this.paused = false, + this.pausedByServer = false, + }); +} + class AppModel extends ChangeNotifier { String get personalName => _personal.name; String wsUrl; @@ -33,6 +47,8 @@ class AppModel extends ChangeNotifier { late ChatPanelModel chatPanel; MainTab mainTab = MainTab.chat; + final lastState = LastState(); + Client _personal = Client(name: 'Unknown', group: 0); List clients = []; @@ -41,7 +57,6 @@ class AppModel extends ChangeNotifier { Timer? _disconnectNotificationTimer; int synchThreshold = 2; - int _prefferedOrientation = 0; bool _isChatVisible = true; bool _showSubtitles = true; bool _hasSystemUi = false; @@ -50,6 +65,16 @@ class AppModel extends ChangeNotifier { Config? config; List playersCacheSupport = []; + Orientation? _prefferedOrientation = null; + + Orientation? get prefferedOrientation => _prefferedOrientation; + + set prefferedOrientation(Orientation orientation) { + if (_prefferedOrientation == orientation) return; + _prefferedOrientation = orientation; + notifyListeners(); + } + bool get hasBackgroundAudio => _hasBackgroundAudio; set hasBackgroundAudio(bool hasBackgroundAudio) { @@ -294,7 +319,6 @@ class AppModel extends ChangeNotifier { if (!player.isVideoLoaded()) return; if (playlist.isEmpty()) { player.pause(); - chatPanel.serverPlay = true; } break; case 'SkipVideo': @@ -309,6 +333,10 @@ class AppModel extends ChangeNotifier { if (playlist.isEmpty()) player.pause(); break; case 'VideoLoaded': + lastState.time = 0; + lastState.paused = false; + lastState.pausedByServer = false; + chatPanel.notifyListeners(); if (!player.isVideoLoaded()) return; player.seekTo( Duration(milliseconds: 0), @@ -316,23 +344,25 @@ class AppModel extends ChangeNotifier { player.play(); break; case 'Pause': - chatPanel.serverPlay = false; + final type = data.pause!; + lastState.time = type.time; + lastState.paused = true; + chatPanel.notifyListeners(); if (!player.isVideoLoaded()) return; if (_personal.isLeader) return; - final type = data.pause!; final ms = (type.time * 1000).round(); player.pause(); - player.seekTo( - Duration(milliseconds: ms), - ); + player.seekTo(Duration(milliseconds: ms)); player.toggleControls(true); player.hideControlsWithDelay(); break; case 'Play': - chatPanel.serverPlay = true; + final type = data.play!; + lastState.time = type.time; + lastState.paused = false; + chatPanel.notifyListeners(); if (!player.isVideoLoaded()) return; if (_personal.isLeader) return; - final type = data.play!; onPlay(type); break; case 'GetTime': @@ -342,6 +372,7 @@ class AppModel extends ChangeNotifier { case 'SetTime': if (_personal.isLeader) return; final type = data.setTime!; + lastState.time = type.time; onTimeSet(type); break; case 'SetRate': @@ -351,10 +382,9 @@ class AppModel extends ChangeNotifier { case 'Rewind': if (!player.isVideoLoaded()) return; final type = data.rewind!; + lastState.time = type.time; final ms = (type.time * 1000 + 500).round(); - player.seekTo( - Duration(milliseconds: ms), - ); + player.seekTo(Duration(milliseconds: ms)); break; case 'SetLeader': final type = data.setLeader!; @@ -439,7 +469,12 @@ class AppModel extends ChangeNotifier { if (duration <= time + synchThreshold) return; if (player.isPlaying() && type.paused) player.pause(); if (!player.isPlaying() && !type.paused) player.play(); - chatPanel.serverPlay = !type.paused; + final isPaused = type.paused || type.pausedByServer; + if (lastState.paused != isPaused) { + lastState.paused = isPaused; + chatPanel.notifyListeners(); + } + if ((time - newTime).abs() < synchThreshold) return; var ms = (newTime * 1000).round(); if (!type.paused) ms += 500; @@ -503,20 +538,6 @@ class AppModel extends ChangeNotifier { isInBackground = false; } - void setPrefferedOrientation(int state) { - _prefferedOrientation = state; - notifyListeners(); - } - - String prefferedOrientationType() { - switch (_prefferedOrientation) { - case 0: - return 'Auto'; - default: - return 'Landscape'; - } - } - void sendVideoItem(AddVideo data) async { if (data.item.url.startsWith('/')) { final relativeHost = getChannelLink(); diff --git a/lib/models/captions.dart b/lib/models/captions.dart new file mode 100644 index 0000000..fa6e4c5 --- /dev/null +++ b/lib/models/captions.dart @@ -0,0 +1,118 @@ +class LocalCaption { + const LocalCaption({ + required this.number, + required this.start, + required this.end, + required this.text, + }); + + final int number; + final Duration start; + final Duration end; + final String text; +} + +abstract class LocalClosedCaptionFile { + List get captions; + + LocalCaption? getCaptionFor(Duration position) { + if (captions.isEmpty) return null; + for (final caption in captions) { + if (position >= caption.start && position <= caption.end) { + return caption; + } + } + return null; + } + + String toSRT() { + final buffer = StringBuffer(); + for (int i = 0; i < captions.length; i++) { + final caption = captions[i]; + buffer.writeln(i + 1); + buffer.writeln( + '${_formatSRTTimestamp(caption.start)} --> ${_formatSRTTimestamp(caption.end)}', + ); + buffer.writeln(caption.text); + buffer.writeln(); // blank line between entries + } + return buffer.toString(); + } + + static String _formatSRTTimestamp(Duration duration) { + final hours = duration.inHours.toString().padLeft(2, '0'); + final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0'); + final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0'); + final milliseconds = (duration.inMilliseconds % 1000).toString().padLeft( + 3, + '0', + ); + return '$hours:$minutes:$seconds,$milliseconds'; + } +} + +class WebVTTCaptionFile extends LocalClosedCaptionFile { + WebVTTCaptionFile(String webvtt) : _captions = _parseWebVTT(webvtt); + + final List _captions; + + @override + List get captions => _captions; + + static List _parseWebVTT(String data) { + final List captions = []; + final lines = data.split('\n'); + int i = 0; + while (i < lines.length) { + final line = lines[i].trim(); + if (line.contains('-->')) { + final times = line.split('-->'); + final start = _parseTimestamp(times[0].trim()); + final end = _parseTimestamp(times[1].trim()); + String text = ''; + i++; + while (i < lines.length && lines[i].trim().isNotEmpty) { + text += lines[i].trim() + '\n'; + i++; + } + captions.add( + LocalCaption( + number: captions.length, + start: start, + end: end, + text: text.trim(), + ), + ); + } + i++; + } + return captions; + } + + static Duration _parseTimestamp(String timestamp) { + final parts = timestamp.split(':'); + if (parts.length == 3) { + final hours = int.parse(parts[0]); + final minutes = int.parse(parts[1]); + final rest = parts[2].split('.'); + final seconds = int.parse(rest[0]); + final milliseconds = int.parse(rest[1].padRight(3, '0').substring(0, 3)); + return Duration( + hours: hours, + minutes: minutes, + seconds: seconds, + milliseconds: milliseconds, + ); + } else { + final minutes = int.parse(parts[0]); + final rest = parts[1].split('.'); + final seconds = int.parse(rest[0]); + final milliseconds = int.parse(rest[1].padRight(3, '0').substring(0, 3)); + return Duration( + minutes: minutes, + seconds: seconds, + milliseconds: milliseconds, + ); + } + } +} diff --git a/lib/models/chat_panel.dart b/lib/models/chat_panel.dart index 544aedb..c4cc693 100644 --- a/lib/models/chat_panel.dart +++ b/lib/models/chat_panel.dart @@ -7,14 +7,13 @@ class ChatPanelModel extends ChangeNotifier { ChatPanelModel(this._app); final AppModel _app; - bool _serverPlay = true; - - bool get serverPlay => _serverPlay; MainTab get mainTab => _app.mainTab; List get clients => _app.clients; + LastState get lastState => _app.lastState; + bool _isConnected = false; bool get isConnected => _isConnected; @@ -46,10 +45,4 @@ class ChatPanelModel extends ChangeNotifier { } togglePanel(MainTab newTab) => _app.togglePanel(newTab); - - set serverPlay(bool serverPlay) { - if (_serverPlay == serverPlay) return; - _serverPlay = serverPlay; - notifyListeners(); - } } diff --git a/lib/models/player.dart b/lib/models/player.dart index 49cf352..5042b6a 100644 --- a/lib/models/player.dart +++ b/lib/models/player.dart @@ -4,7 +4,8 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import 'package:video_player/video_player.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart' as youtube; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; @@ -13,15 +14,18 @@ import '../subs/ass.dart'; import '../subs/raw.dart'; import '../wsdata.dart'; import './app.dart'; +import './captions.dart'; import './playlist.dart'; class PlayerModel extends ChangeNotifier { - VideoPlayerController? controller; + late final Player player; + late final VideoController controller; Future? initPlayerFuture; final AppModel app; final PlaylistModel playlist; bool showControls = false; + bool hasSubtitleTrack = false; Timer? controlsTimer; bool _showMessageIcon = false; @@ -42,14 +46,34 @@ class PlayerModel extends ChangeNotifier { notifyListeners(); } - PlayerModel(this.app, this.playlist); + PlayerModel(this.app, this.playlist) { + player = Player(); + controller = VideoController( + player, + // configuration: VideoControllerConfiguration( + // androidAttachSurfaceAfterVideoParameters: false, + // enableHardwareAcceleration: false, + // ), + ); + + player.stream.completed.listen((completed) { + if (completed) { + // sync with server? + } + }); + + player.stream.position.listen((_) => notifyListeners()); + player.stream.playing.listen((_) => notifyListeners()); + player.stream.buffer.listen((_) => notifyListeners()); + player.stream.rate.listen((_) => notifyListeners()); + } bool isVideoLoaded() { - return controller?.value.isInitialized ?? false; + return player.state.width != null && player.state.height != null; } bool isPlaying() { - return controller?.value.isPlaying ?? false; + return player.state.playing; } void toggleControls(bool flag) { @@ -73,38 +97,38 @@ class PlayerModel extends ChangeNotifier { } Future getPosition() async { - if (!isVideoLoaded()) return Duration(); - final posD = await controller?.position; - if (posD == null) return Duration(); - return posD; + return player.state.position; } void pause() async { - if (!isVideoLoaded()) return; - await controller?.pause(); + await player.pause(); } void play() async { - if (!isVideoLoaded()) return; if (app.isInBackground) { if (!app.hasBackgroundAudio) return; } - await controller?.play(); + await player.play(); } void seekTo(Duration duration) async { - if (!isVideoLoaded()) return; - await controller?.seekTo(duration); + await player.seek(duration); } Future getPlaybackSpeed() async { - if (!isVideoLoaded()) return 1.0; - return controller?.value.playbackSpeed ?? 1.0; + return player.state.rate; } void setPlaybackSpeed(double rate) async { - if (!isVideoLoaded()) return; - await controller?.setPlaybackSpeed(rate); + await player.setRate(rate); + notifyListeners(); + if (!app.isLeader()) return; + app.send( + WsData( + type: 'SetRate', + setRate: SetRate(rate: rate), + ), + ); } double getDuration() { @@ -131,13 +155,8 @@ class PlayerModel extends ChangeNotifier { final item = playlist.getItem(playlist.pos); if (item == null) return; if (isIframe()) { - final old = controller; - controller = null; initPlayerFuture = Future.microtask(() => null); notifyListeners(); - initPlayerFuture?.whenComplete(() { - Future.delayed(const Duration(seconds: 1), () => old?.dispose()); - }); return; } var url = item.url; @@ -145,22 +164,31 @@ class PlayerModel extends ChangeNotifier { final relativeHost = app.getChannelLink(); url = '$relativeHost${url}'; } - if (url.contains('youtu')) url = await getYoutubeVideoUrl(url); + String? audioUrl; + if (url.contains('youtu')) { + final ytData = await getYoutubeVideoUrl(url); + url = ytData.video; + audioUrl = ytData.audio; + } pause(); - final prevController = controller; - controller = VideoPlayerController.networkUrl( - Uri.parse(url), - // closedCaptionFile: _loadCaptions(item), - videoPlayerOptions: VideoPlayerOptions( - mixWithOthers: true, - allowBackgroundPlayback: true, - ), - ); - controller?.addListener(notifyListeners); - initPlayerFuture = controller?.initialize(); + + initPlayerFuture = () async { + await player.open(Media(url)); + if (audioUrl != null) { + await player.setAudioTrack(AudioTrack.uri(audioUrl)); + } + }(); + initPlayerFuture?.whenComplete(() { - prevController?.dispose(); - controller?.setClosedCaptionFile(_loadCaptions(item)).whenComplete(() { + hasSubtitleTrack = false; + _loadCaptions(item)?.then((subs) { + var srt = subs.toSRT(); + hasSubtitleTrack = srt.isNotEmpty; + player.setSubtitleTrack( + SubtitleTrack.data( + srt, + ), + ); notifyListeners(); }); app.send( @@ -174,14 +202,32 @@ class PlayerModel extends ChangeNotifier { notifyListeners(); } + String fullscreenTooltipText = 'Double-tap or long-tap to toggle fullscreen'; + Future getVideoDuration(String url) async { - if (url.contains('youtu')) url = await getYoutubeVideoUrl(url); - final controller = VideoPlayerController.networkUrl(Uri.parse(url)); + // mediakit will parse segment durations for a minute while loading them, + // so we need to parse times itself (or make better exoplayer fork) + if (url.contains('.m3u8')) { + try { + final duration = await _getHlsDuration(url); + print('[getVideoDuration] HLS parsed duration=$duration'); + return duration; + } catch (e) { + print('[getVideoDuration] HLS parse failed: $e'); + return 0; + } + } + if (url.contains('youtu')) { + url = (await getYoutubeVideoUrl(url)).video; + } + final tempPlayer = Player(); Duration? duration; try { - await controller.initialize(); - duration = controller.value.duration; - controller.dispose(); + await tempPlayer.open(Media(url), play: false); + // Wait a bit for duration to be parsed + await Future.delayed(const Duration(milliseconds: 500)); + duration = tempPlayer.state.duration; + await tempPlayer.dispose(); } catch (e) { print(e.toString()); } @@ -189,6 +235,63 @@ class PlayerModel extends ChangeNotifier { return duration.inMilliseconds / 1000; } + Future _getHlsDuration(String url) async { + final response = await http + .get(Uri.parse(url)) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + // print('[_getHlsDuration] bad status: ${response.statusCode}'); + return 0; + } + + final body = response.body; + // print('[_getHlsDuration] manifest length=${body.length}'); + + // Master playlist — find and follow the first variant stream + if (body.contains('#EXT-X-STREAM-INF')) { + // print('[_getHlsDuration] master playlist detected, following first variant'); + final lines = body + .split('\n') + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .toList(); + for (var i = 0; i < lines.length; i++) { + if (lines[i].startsWith('#EXT-X-STREAM-INF') && i + 1 < lines.length) { + var variantUrl = lines[i + 1]; + if (!variantUrl.startsWith('http')) { + final base = Uri.parse(url); + variantUrl = base.resolve(variantUrl).toString(); + } + // print('[_getHlsDuration] variant url=$variantUrl'); + return _getHlsDuration(variantUrl); + } + } + // print('[_getHlsDuration] no variant found in master playlist'); + return 0; + } + + // Media playlist — sum #EXTINF durations + // Also check for #EXT-X-ENDLIST (VOD vs live) + final isVod = body.contains('#EXT-X-ENDLIST'); + + if (!isVod) { + // Live stream — return sentinel value + // print('[_getHlsDuration] live stream, returning sentinel 356400'); + return 356400.0; + } + + double total = 0; + final extinfReg = RegExp(r'#EXTINF:([\d.]+)'); + for (final match in extinfReg.allMatches(body)) { + final seg = double.tryParse(match.group(1)!) ?? 0; + total += seg; + } + + // print('[_getHlsDuration] summed ${extinfReg.allMatches(body).length} segments, total=$total'); + return total; + } + static String extractVideoId(String url) { if (url.contains('youtu.be/')) { return RegExp(r'youtu.be\/([A-z0-9_-]+)').firstMatch(url)!.group(1)!; @@ -206,7 +309,7 @@ class PlayerModel extends ChangeNotifier { return r.firstMatch(url)!.group(1)!; } - Future getYoutubeVideoUrl(String url) async { + Future<({String video, String? audio})> getYoutubeVideoUrl(String url) async { final yt = youtube.YoutubeExplode(); try { final id = extractVideoId(url); @@ -219,30 +322,58 @@ class PlayerModel extends ChangeNotifier { } catch (e) { print(e); app.chat.addItem(ChatItem('', e.toString())); - return ''; + return (video: '', audio: null); } - yt.close(); - // final stream = manifest.muxed.withHighestBitrate(); - final qualities = manifest.muxed.getAllVideoQualities().toList(); + + final videoStreams = manifest.videoOnly.toList(); final values = youtube.VideoQuality.values; - qualities.sort((a, b) { - return values.indexOf(a).compareTo(values.indexOf(b)); + videoStreams.sort((a, b) { + return values + .indexOf(a.videoQuality) + .compareTo(values.indexOf(b.videoQuality)); }); - while (values.indexOf(qualities.last) > - values.indexOf(youtube.VideoQuality.high1080)) { - qualities.removeLast(); + + // Filter to max 1080p + final filteredVideo = videoStreams.where((s) { + return values.indexOf(s.videoQuality) <= + values.indexOf(youtube.VideoQuality.high1080); + }).toList(); + + final audioStream = manifest.audioOnly.isEmpty + ? null + : manifest.audioOnly.withHighestBitrate(); + + if (filteredVideo.isNotEmpty && audioStream != null) { + yt.close(); + return ( + video: filteredVideo.last.url.toString(), + audio: audioStream.url.toString(), + ); } - // print(qualities); - final stream = manifest.muxed.firstWhere((element) { - return element.videoQuality == qualities.last; + + // fallback to muxed + final muxedStreams = manifest.muxed.toList(); + muxedStreams.sort((a, b) { + return values + .indexOf(a.videoQuality) + .compareTo(values.indexOf(b.videoQuality)); }); - final streamUrl = stream.url.toString(); - return streamUrl; + final filteredMuxed = muxedStreams.where((s) { + return values.indexOf(s.videoQuality) <= + values.indexOf(youtube.VideoQuality.high1080); + }).toList(); + + yt.close(); + if (filteredMuxed.isNotEmpty) { + return (video: filteredMuxed.last.url.toString(), audio: null); + } + + return (video: '', audio: null); } catch (e) { print('getYoutubeVideoUrl error for url $url'); print(e); yt.close(); - return ''; + return (video: '', audio: null); } } @@ -326,10 +457,9 @@ class PlayerModel extends ChangeNotifier { return total == 0 ? 356400.0 : total.toDouble(); } - Future? _loadCaptions(VideoList item) { + Future? _loadCaptions(VideoList item) { if (item.url.contains('youtu')) { - item.subs = item.url; - return compute(_loadYoutubeCaptionsFuture, item); + return compute(_loadYoutubeCaptionsFuture, item.url); } var subsUrl = item.subs ?? ''; if (subsUrl.isEmpty) return null; @@ -343,15 +473,14 @@ class PlayerModel extends ChangeNotifier { return compute(_loadCaptionsFuture, subsUrl); } - static Future _loadYoutubeCaptionsFuture( - VideoList item, + static Future _loadYoutubeCaptionsFuture( + String url, ) async { - final subs = await getYoutubeSubtitles(item.url); - if (subs.captions.isEmpty) item.subs = ''; + final subs = await getYoutubeSubtitles(url); return subs; } - static Future _loadCaptionsFuture(String url) async { + static Future _loadCaptionsFuture(String url) async { Response response; try { response = await http.get(Uri.parse(url)).timeout(Duration(seconds: 5)); @@ -445,7 +574,7 @@ class PlayerModel extends ChangeNotifier { return blocks; } - static Future getYoutubeSubtitles(String url) async { + static Future getYoutubeSubtitles(String url) async { final yt = youtube.YoutubeExplode(); try { final id = extractVideoId(url); @@ -465,7 +594,7 @@ class PlayerModel extends ChangeNotifier { } var i = 0; final items = track.captions.map((e) { - final caption = Caption( + final caption = LocalCaption( number: i, start: e.offset, end: e.offset + e.duration, @@ -483,6 +612,7 @@ class PlayerModel extends ChangeNotifier { } bool hasCaptions() { + if (hasSubtitleTrack) return true; final item = playlist.getItem(playlist.pos); final subs = item?.subs; return subs != null && subs.isNotEmpty; @@ -496,7 +626,7 @@ class PlayerModel extends ChangeNotifier { void sendPlayerState(bool state) async { if (!isVideoLoaded()) return; if (!app.isLeader()) return; - final posD = await controller?.position ?? Duration(); + final posD = player.state.position; final time = posD.inMilliseconds / 1000; if (state) { app.send( @@ -518,8 +648,7 @@ class PlayerModel extends ChangeNotifier { @override void dispose() async { print('PlayerModel disposed'); - await controller?.dispose(); - controller = null; + await player.dispose(); super.dispose(); } } diff --git a/lib/settings.dart b/lib/settings.dart index d01b83c..5b1c1ee 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -31,6 +31,16 @@ class ChannelPreferences { } class Settings extends StatelessWidget { + static void load(AppModel app) async { + final prefs = await SharedPreferencesAsync(); + final orientationI = await prefs.getInt('prefferedOrientation') ?? 0; + setPrefferedOrientation(app, Orientation.values[orientationI], save: false); + setSystemUi(app, await prefs.getBool('hasSystemUi') ?? false); + app.hasBackgroundAudio = await prefs.getBool('backgroundAudio') ?? true; + checkedCache = await prefs.getStringList('checkedCache') ?? []; + // ChannelPreferences are per-channel, so not loaded globally here + } + @override Widget build(BuildContext context) { final app = context.watch(); @@ -38,15 +48,12 @@ class Settings extends StatelessWidget { children: [ ListTile( title: const Text('Orientation'), - trailing: Text('${app.prefferedOrientationType()}'), + trailing: Text('${prefferedOrientationText(app)}'), onTap: () async { - final prefs = await SharedPreferencesAsync(); - final key = 'prefferedOrientation'; - var state = await prefs.getInt(key) ?? 0; - state++; - if (state > 1) state = 0; - setPrefferedOrientation(app, state); - await prefs.setInt(key, state); + var orientationI = app.prefferedOrientation?.index ?? 0; + orientationI++; + if (orientationI > 1) orientationI = 0; + setPrefferedOrientation(app, Orientation.values[orientationI]); }, ), SwitchListTile( @@ -78,6 +85,17 @@ class Settings extends StatelessWidget { ); } + String prefferedOrientationText(AppModel app) { + switch (app.prefferedOrientation) { + case null: + return 'Auto'; + case .portrait: + return 'Portrait'; + case .landscape: + return 'Landscape'; + } + } + /// Get ChannelPreferences for a given channelUrl (server list key) static Future getChannelPreferences( String channelUrl, @@ -111,34 +129,21 @@ class Settings extends StatelessWidget { await prefs.remove('channelPrefs_$channelUrl'); } - static void nextOrientationView(AppModel app) async { - final prefs = await SharedPreferencesAsync(); - final key = 'prefferedOrientation'; - var state = await prefs.getInt(key) ?? 0; - switch (state) { - case 0: - state++; - setPrefferedOrientation(app, state); - await prefs.setInt(key, state); - break; - case 1: - app.isChatVisible = !app.isChatVisible; - break; - } - SystemChrome.restoreSystemUIOverlays(); - } - static var isTV = false; static List checkedCache = []; static List prefferedOrientations = []; - static void setPrefferedOrientation(AppModel app, int state) async { - switch (state) { - case 0: + static void setPrefferedOrientation( + AppModel app, + Orientation orientation, { + save = true, + }) async { + switch (orientation) { + case .portrait: prefferedOrientations = []; break; - case 1: + case .landscape: prefferedOrientations = [ DeviceOrientation.landscapeRight, DeviceOrientation.landscapeLeft, @@ -154,7 +159,11 @@ class Settings extends StatelessWidget { break; } SystemChrome.setPreferredOrientations(prefferedOrientations); - app.setPrefferedOrientation(state); + app.prefferedOrientation = orientation; + + if (!save) return; + final prefs = await SharedPreferencesAsync(); + prefs.setInt('prefferedOrientation', orientation.index); } static void setSystemUi(AppModel app, bool flag) { @@ -178,16 +187,4 @@ class Settings extends StatelessWidget { if (checked) checkedCache.add(playerType); await prefs.setStringList('checkedCache', checkedCache); } - - static void applySettings(AppModel app) async { - final prefs = await SharedPreferencesAsync(); - setPrefferedOrientation( - app, - await prefs.getInt('prefferedOrientation') ?? 0, - ); - setSystemUi(app, await prefs.getBool('hasSystemUi') ?? false); - app.hasBackgroundAudio = await prefs.getBool('backgroundAudio') ?? true; - checkedCache = await prefs.getStringList('checkedCache') ?? []; - // ChannelPreferences are per-channel, so not loaded globally here - } } diff --git a/lib/subs/ass.dart b/lib/subs/ass.dart index 0eaba5e..e66f59e 100644 --- a/lib/subs/ass.dart +++ b/lib/subs/ass.dart @@ -1,28 +1,28 @@ import 'dart:convert'; -import 'package:video_player/video_player.dart'; +import '../models/captions.dart'; final _assTimeStamp = RegExp(r'\d+:\d\d:\d\d.\d\d'); -final _blockTags = RegExp(r'\{\\[^}]*\}'); +final _blockTags = RegExp(r'\{\\+[^}]*\}'); final _spaceTags = RegExp(r'\\(n|h)'); final _newLineTag = RegExp(r'\\N'); final _manyNewLineTags = RegExp(r'\\N(\\N)+'); final _drawingMode = RegExp(r'\\p[124]'); -/// Represents a [ClosedCaptionFile], parsed from the ASS file format. -class AssCaptionFile extends ClosedCaptionFile { +/// Represents a [LocalClosedCaptionFile], parsed from the ASS file format. +class AssCaptionFile extends LocalClosedCaptionFile { AssCaptionFile(this.fileContents) : _captions = _parseCaptionsFromAssString(fileContents); final String fileContents; - final List _captions; + final List _captions; @override - List get captions => _captions; + List get captions => _captions; } -List _parseCaptionsFromAssString(String file) { - final List captions = []; +List _parseCaptionsFromAssString(String file) { + final List captions = []; if (file.isEmpty) return captions; final List lines = LineSplitter.split(file).toList(); @@ -57,7 +57,7 @@ List _parseCaptionsFromAssString(String file) { text = text.replaceAll(_spaceTags, ' '); text = text.replaceAll(_manyNewLineTags, '\\N'); text = text.replaceAll(_newLineTag, '\n'); - final caption = Caption( + final caption = LocalCaption( number: captionNumber, start: _parseAssTimestamp(list[ids['Start']!]), end: _parseAssTimestamp(list[ids['End']!]), diff --git a/lib/subs/raw.dart b/lib/subs/raw.dart index 1c1f4d6..a83e70e 100644 --- a/lib/subs/raw.dart +++ b/lib/subs/raw.dart @@ -1,10 +1,10 @@ -import 'package:video_player/video_player.dart'; +import '../models/captions.dart'; -class RawCaptionFile extends ClosedCaptionFile { +class RawCaptionFile extends LocalClosedCaptionFile { RawCaptionFile(this._captions); - final List _captions; + final List _captions; @override - List get captions => _captions; + List get captions => _captions; } diff --git a/lib/utils/multi_tap_recognizer.dart b/lib/utils/multi_tap_recognizer.dart new file mode 100644 index 0000000..bbfede1 --- /dev/null +++ b/lib/utils/multi_tap_recognizer.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class MultiTapListener extends StatefulWidget { + final Widget child; + final void Function() onDoubleTap; + + const MultiTapListener({required this.onDoubleTap, required this.child}); + + @override + State createState() => _MultiTapListenerState(); +} + +class _MultiTapListenerState extends State { + int _tapCount = 0; + Timer? _resetTimer; + Offset? _lastPosition; + DateTime? _pointerDownTime; + + // A tap held longer than this is considered a long press + static const _longPressThreshold = Duration(milliseconds: 300); + + void _onPointerDown(PointerDownEvent event) { + _pointerDownTime = DateTime.now(); + } + + void _onPointerUp(PointerUpEvent event) { + final downTime = _pointerDownTime; + _pointerDownTime = null; + + // If finger was held too long, it's a long press — reset and ignore + if (downTime == null || + DateTime.now().difference(downTime) > _longPressThreshold) { + _tapCount = 0; + _resetTimer?.cancel(); + return; + } + + // Reset if taps are too far apart spatially + if (_lastPosition != null && + (event.position - _lastPosition!).distance > kDoubleTapSlop) { + _tapCount = 0; + } + _lastPosition = event.position; + _tapCount++; + + if (_tapCount >= 2) { + _tapCount = 0; + _resetTimer?.cancel(); + widget.onDoubleTap(); + } else { + _resetTimer?.cancel(); + // Reset if second tap doesn't arrive in time + _resetTimer = Timer(kDoubleTapTimeout, () { + _tapCount = 0; + }); + } + } + + @override + void dispose() { + _resetTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _onPointerDown, + onPointerUp: _onPointerUp, + child: widget.child, + ); + } +} diff --git a/lib/video_player.dart b/lib/video_player.dart index cbf87ff..313de2a 100644 --- a/lib/video_player.dart +++ b/lib/video_player.dart @@ -1,10 +1,14 @@ +import 'dart:math' as math; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:media_kit_video/media_kit_video.dart'; // import 'package:perfect_volume_control/perfect_volume_control.dart'; import 'package:provider/provider.dart'; +import 'package:synctube/models/app.dart'; +import 'package:synctube/utils/multi_tap_recognizer.dart'; import 'package:url_launcher/url_launcher_string.dart'; -import 'package:video_player/video_player.dart'; import 'chat_panel.dart'; import 'color_scheme.dart'; @@ -23,32 +27,31 @@ class VideoPlayerScreen extends StatelessWidget { future: player.initPlayerFuture, builder: (context, snapshot) { switch (snapshot.connectionState) { - case ConnectionState.waiting: - case ConnectionState.active: + case .waiting: + case .active: return const Center(child: CircularProgressIndicator()); - case ConnectionState.done: + case .done: if (Settings.isTV) { return TvControls( child: buildPlayer(player), ); } return buildPlayer(player); - default: + case .none: return GestureDetector( child: Settings.isTV ? tvEmptyPlayerWidget(context) : const SizedBox.expand(), behavior: HitTestBehavior.translucent, + // don't allow to go fullscreen on empty player, only enter landscape onDoubleTap: () { - if (player.app.prefferedOrientationType() == 'Landscape') - return; - Settings.nextOrientationView(player.app); + if (player.app.prefferedOrientation == .landscape) return; + goLandscapeOrFullscreen(player.app); }, onLongPress: () { - if (player.app.prefferedOrientationType() == 'Landscape') - return; - Settings.nextOrientationView(player.app); + if (player.app.prefferedOrientation == .landscape) return; + goLandscapeOrFullscreen(player.app); }, ); } @@ -58,52 +61,58 @@ class VideoPlayerScreen extends StatelessWidget { Widget buildPlayer(PlayerModel player) { if (player.isIframe()) return iframeWidget(player); - final captionText = player.controller?.value.caption.text; - return GestureDetector( - onDoubleTap: () => Settings.nextOrientationView(player.app), - onLongPress: () { - if (player.showControls) return; - Settings.nextOrientationView(player.app); - }, - child: Stack( - children: [ - Stack( - fit: StackFit.expand, - children: [ - FittedBox( - fit: player.isFitWidth ? BoxFit.fitWidth : BoxFit.contain, - child: SizedBox( - width: player.controller?.value.aspectRatio ?? 16 / 9, - height: 1, - child: VideoPlayer(player.controller!), + + final playerState = player.player.state; + final playerWidth = playerState.width?.toDouble() ?? (1280 / 2); + final playerHeight = playerState.height?.toDouble() ?? (1280 / 2); + + return MultiTapListener( + onDoubleTap: () => goLandscapeOrFullscreen(player.app), + child: GestureDetector( + // onDoubleTap: () => goLandscapeOrFullscreen(player.app), + onLongPress: () { + if (player.showControls) return; + goLandscapeOrFullscreen(player.app); + }, + child: Stack( + children: [ + Stack( + fit: StackFit.expand, + children: [ + Video( + controller: player.controller, + fit: player.isFitWidth ? BoxFit.fitWidth : BoxFit.contain, + width: playerWidth, + height: playerHeight, + pauseUponEnteringBackgroundMode: + !player.app.hasBackgroundAudio, + subtitleViewConfiguration: SubtitleViewConfiguration( + textScaler: .linear(0.55), + visible: player.app.showSubtitles, + ), ), - ), - ], - ), - if (player.app.showSubtitles && - captionText != null && - captionText.isNotEmpty) - ClosedCaption( - text: captionText, - textStyle: const TextStyle(fontSize: 16), - ), - _PlayPauseOverlay(player: player), - AnimatedOpacity( - opacity: player.showMessageIcon ? 0.7 : 0, - duration: const Duration(milliseconds: 200), - child: const Align( - alignment: Alignment.topRight, - child: Padding( - padding: const EdgeInsets.all(10), - child: Icon(Icons.mail), - ), + ], ), - ), - ], + _PlayPauseOverlay(player: player), + _ChatToggleButton(player: player), + ], + ), ), ); } + void goLandscapeOrFullscreen(AppModel app) { + final orientation = app.prefferedOrientation; + switch (orientation) { + case .portrait: + Settings.setPrefferedOrientation(app, .landscape); + case .landscape: + app.isChatVisible = !app.isChatVisible; + case null: + } + SystemChrome.restoreSystemUIOverlays(); + } + Widget iframeWidget(PlayerModel player) { return GestureDetector( onTap: () async { @@ -150,10 +159,10 @@ class _PlayPauseOverlay extends StatelessWidget { player.userSetPlayerState(!player.isPlaying()); } - String _timeText(VideoPlayerValue? value) { - if (value == null) return ''; - final p = _stringDuration(value.position); - final d = _stringDuration(value.duration); + String _timeText(PlayerModel player) { + final state = player.player.state; + final p = _stringDuration(state.position); + final d = _stringDuration(state.duration); return '$p / $d'; } @@ -197,33 +206,10 @@ class _PlayPauseOverlay extends StatelessWidget { alignment: Alignment.bottomLeft, child: Padding( padding: const EdgeInsets.only(bottom: 32, left: 15), - child: Text(_timeText(player.controller?.value)), - ), - ), - if (player.showControls) - GestureDetector( - onHorizontalDragDown: (details) { - player.cancelControlsHide(); - }, - child: Align( - alignment: Alignment.bottomCenter, - child: VideoProgressIndicator( - player.controller!, - padding: const EdgeInsets.only( - bottom: 15, - top: 5, - left: 15, - right: 15, - ), - colors: VideoProgressColors( - playedColor: const Color.fromRGBO(200, 0, 0, 0.75), - bufferedColor: const Color.fromRGBO(200, 200, 200, 0.5), - backgroundColor: const Color.fromRGBO(200, 200, 200, 0.2), - ), - allowScrubbing: true, - ), + child: Text(_timeText(player)), ), ), + if (player.showControls) _buildVideoProgress(context), if (player.showControls) Align( alignment: Alignment.topLeft, @@ -240,6 +226,7 @@ class _PlayPauseOverlay extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ + _buildPlaybackRateButton(context), IconButton( icon: Icon( player.isFitWidth @@ -267,15 +254,26 @@ class _PlayPauseOverlay extends StatelessWidget { ), IconButton( icon: Icon( - player.app.isChatVisible - ? Icons.fullscreen - : Icons.fullscreen_exit, + Icons.screen_rotation, color: Theme.of(context).playerIcon, - size: 30, + size: 23, ), - tooltip: 'Double-tap or long-tap for fullscreen', + tooltip: player.fullscreenTooltipText, onPressed: () { - Settings.nextOrientationView(player.app); + final orientation = MediaQuery.of(context).orientation; + switch (orientation) { + case .portrait: + Settings.setPrefferedOrientation( + player.app, + .landscape, + ); + case .landscape: + player.app.isChatVisible = true; + Settings.setPrefferedOrientation( + player.app, + .portrait, + ); + } }, ), ], @@ -286,6 +284,126 @@ class _PlayPauseOverlay extends StatelessWidget { ), ); } + + PopupMenuButton _buildPlaybackRateButton(BuildContext context) { + return PopupMenuButton( + padding: EdgeInsets.zero, + child: GestureDetector( + onLongPress: () { + player.setPlaybackSpeed(1.0); + HapticFeedback.mediumImpact(); + }, + child: Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + ), + child: Icon( + Icons.speed_outlined, + color: Theme.of(context).playerIcon, + size: 30, + ), + ), + if (player.player.state.rate != 1.0) + Positioned( + top: -2, + right: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: Color.fromRGBO(0, 0, 0, 0.75), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + player.player.state.rate.toString().replaceAll('.0', ''), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + initialValue: player.player.state.rate, + onSelected: (double speed) { + player.setPlaybackSpeed(speed); + player.hideControlsWithDelay(); + }, + onOpened: () => player.cancelControlsHide(), + onCanceled: () => player.hideControlsWithDelay(), + itemBuilder: (BuildContext context) { + return [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0].map( + (double speed) { + return PopupMenuItem( + value: speed, + child: Text('${speed}'), + ); + }, + ).toList(); + }, + ); + } + + GestureDetector _buildVideoProgress(BuildContext context) { + var durationMs = player.player.state.duration.inMilliseconds.toDouble(); + if (durationMs == 0) durationMs = 1; + var posMs = player.player.state.position.inMilliseconds.toDouble(); + var bufMs = player.player.state.buffer.inMilliseconds.toDouble(); + posMs = posMs.clamp(0, durationMs); + + return GestureDetector( + onHorizontalDragDown: (details) { + player.cancelControlsHide(); + }, + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only( + bottom: 15, + top: 5, + left: 15, + right: 15, + ), + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 2, + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6, + ), + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 12, + ), + ), + child: SizedBox( + height: 15, + child: Slider( + value: posMs, + min: 0, + max: durationMs, + secondaryTrackValue: bufMs, + onChanged: (value) { + player.seekTo( + Duration(milliseconds: value.toInt()), + ); + }, + activeColor: const Color.fromRGBO(200, 0, 0, 0.75), + inactiveColor: const Color.fromRGBO(200, 200, 200, 0.2), + secondaryActiveColor: const Color.fromRGBO(255, 255, 255, 0.3), + ), + ), + ), + ), + ), + ); + } } class AllowMultipleGestureRecognizer extends TapGestureRecognizer { @@ -343,3 +461,98 @@ class TvControls extends StatelessWidget { ); } } + +class _ChatToggleButton extends StatelessWidget { + const _ChatToggleButton({ + Key? key, + required this.player, + }) : super(key: key); + + final PlayerModel player; + + @override + Widget build(BuildContext context) { + if (MediaQuery.of(context).orientation == .portrait) { + return const SizedBox.shrink(); + } + + final app = player.app; + final showControls = player.showControls; + final showMessageIcon = player.showMessageIcon; + // final showMessageIcon = true; + + if (!showControls && !showMessageIcon) { + return const SizedBox.shrink(); + } + + return Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(top: 5), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: showControls + ? IconButton( + icon: _buildChatIcon(context, app.isChatVisible), + tooltip: player.fullscreenTooltipText, + onPressed: () { + app.isChatVisible = !app.isChatVisible; + player.hideControlsWithDelay(); + }, + ) + : _buildUnreadIcon(context), + ), + ), + ); + } + + Widget _buildUnreadIcon(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(12), + child: Icon( + Icons.chat_bubble, + color: Theme.of( + context, + ).playerIcon.withValues(alpha: 0.5), + size: 24, + shadows: [ + Shadow( + color: Color.fromRGBO(0, 0, 0, 0.1), + blurRadius: 10, + ), + ], + ), + ); + } + + Widget _buildChatIcon(BuildContext context, bool isVisible) { + final color = Theme.of(context).playerIcon; + const icon = Icons.chat_bubble_outline; + const size = 24.0; + + if (!isVisible) { + return const Icon(icon, color: Colors.white, size: size); + } + + return Stack( + alignment: Alignment.center, + children: [ + Icon(icon, color: color, size: size), + Transform.translate( + offset: Offset(0, -1.5), + child: Transform.rotate( + angle: -45 * (math.pi / 180.0), + child: Container( + width: 2, + height: 28, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(1), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/wsdata.dart b/lib/wsdata.dart index ea1a7a8..ac09528 100644 --- a/lib/wsdata.dart +++ b/lib/wsdata.dart @@ -826,20 +826,28 @@ class Pause { class GetTime { late double time; late bool paused; + late bool pausedByServer; late double rate; - GetTime({required this.time, required this.paused, required this.rate}); + GetTime({ + required this.time, + required this.paused, + required this.pausedByServer, + required this.rate, + }); GetTime.fromJson(Map json) { time = json['time'].toDouble(); paused = json['paused'] ?? false; - rate = json['rate'] ?? 1; + pausedByServer = json['pausedByServer'] ?? false; + rate = json['rate']?.toDouble() ?? 1; } Map toJson() { final Map data = new Map(); data['time'] = this.time; data['paused'] = this.paused; + data['pausedByServer'] = this.pausedByServer; data['rate'] = this.rate; return data; } @@ -851,7 +859,7 @@ class SetRate { SetRate({required this.rate}); SetRate.fromJson(Map json) { - rate = json['rate']; + rate = json['rate'].toDouble(); } Map toJson() { diff --git a/pubspec.lock b/pubspec.lock index c252682..e370f27 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.9" args: dependency: transitive description: @@ -93,10 +93,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" url: "https://pub.dev" source: hosted - version: "0.3.5+1" + version: "0.3.5+2" crypto: dependency: "direct main" description: @@ -117,10 +117,10 @@ packages: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" device_info_plus: dependency: "direct main" description: @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" file: dependency: transitive description: @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "10.3.10" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -232,14 +240,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.dev" + source: hosted + version: "4.8.0" json_annotation: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.11.0" leak_tracker: dependency: transitive description: @@ -288,6 +304,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + media_kit: + dependency: "direct main" + description: + name: media_kit + sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643 + url: "https://pub.dev" + source: hosted + version: "1.2.6" + media_kit_libs_android_video: + dependency: "direct main" + description: + name: media_kit_libs_android_video + sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c" + url: "https://pub.dev" + source: hosted + version: "1.3.8" + media_kit_video: + dependency: "direct main" + description: + name: media_kit_video + sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d + url: "https://pub.dev" + source: hosted + version: "2.0.1" meta: dependency: transitive description: @@ -372,10 +412,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -396,10 +436,10 @@ packages: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.5.0" provider: dependency: "direct main" description: @@ -408,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.5+1" + safe_local_storage: + dependency: transitive + description: + name: safe_local_storage + sha256: "287ea1f667c0b93cdc127dccc707158e2d81ee59fba0459c31a0c7da4d09c755" + url: "https://pub.dev" + source: hosted + version: "2.0.3" shared_preferences: dependency: "direct main" description: @@ -420,10 +468,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f url: "https://pub.dev" source: hosted - version: "2.4.18" + version: "2.4.20" shared_preferences_foundation: dependency: transitive description: @@ -481,10 +529,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" stack_trace: dependency: transitive description: @@ -509,6 +557,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -537,10 +593,26 @@ packages: dependency: transitive description: name: unicode - sha256: "0d99edbd2e74726bed2e4989713c8bec02e5581628e334d8c88c0271593fb402" + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 url: "https://pub.dev" source: hosted - version: "1.1.8" + version: "1.1.9" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + uri_parser: + dependency: transitive + description: + name: uri_parser + sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce" + url: "https://pub.dev" + source: hosted + version: "3.0.2" url_launcher: dependency: "direct main" description: @@ -561,10 +633,10 @@ packages: dependency: transitive description: name: url_launcher_ios - sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.6" + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -593,10 +665,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" url_launcher_windows: dependency: transitive description: @@ -605,54 +677,22 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" - vector_math: + uuid: dependency: transitive description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - video_player: - dependency: "direct main" - description: - name: video_player - sha256: "08bfba72e311d48219acad4e191b1f9c27ff8cf928f2c7234874592d9c9d7341" + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "2.11.0" - video_player_android: - dependency: transitive - description: - name: video_player_android - sha256: ee4fd520b0cafa02e4a867a0f882092e727cdaa1a2d24762171e787f8a502b0a - url: "https://pub.dev" - source: hosted - version: "2.9.1" - video_player_avfoundation: - dependency: transitive - description: - name: video_player_avfoundation - sha256: f93b93a3baa12ca0ff7d00ca8bc60c1ecd96865568a01ff0c18a99853ee201a5 - url: "https://pub.dev" - source: hosted - version: "2.9.3" - video_player_platform_interface: - dependency: transitive - description: - name: video_player_platform_interface - sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" - url: "https://pub.dev" - source: hosted - version: "6.6.0" - video_player_web: + version: "4.5.3" + vector_math: dependency: transitive description: - name: video_player_web - sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.2.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 61d1977..51ae1ef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.0.0+34 environment: - sdk: ^3.9.0 + sdk: ^3.10.0 dependencies: flutter: @@ -20,7 +20,11 @@ dependencies: shared_preferences: ^2.5.3 - video_player: ^2.11.0 + media_kit: ^1.2.6 + + media_kit_video: ^2.0.1 + + media_kit_libs_android_video: ^1.3.8 # perfect_volume_control: ^1.0.5