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