From a2ba495da591c83960ee11dee3615393c53916b4 Mon Sep 17 00:00:00 2001 From: RblSb Date: Sun, 22 Feb 2026 22:25:54 +0300 Subject: [PATCH 01/13] Something works - mediakit - better yt video quality with external audiotrack url - remove doubletap gesture player lag - fullscreen button replaced --- android/app/src/main/AndroidManifest.xml | 4 +- lib/app.dart | 20 +- lib/main.dart | 3 + lib/models/app.dart | 27 ++- lib/models/captions.dart | 93 +++++++++ lib/models/player.dart | 173 +++++++++------- lib/settings.dart | 81 ++++---- lib/subs/ass.dart | 18 +- lib/subs/raw.dart | 8 +- lib/utils/multi_tap_recognizer.dart | 77 ++++++++ lib/video_player.dart | 238 +++++++++++++++-------- pubspec.lock | 168 ++++++++++------ pubspec.yaml | 8 +- 13 files changed, 621 insertions(+), 297 deletions(-) create mode 100644 lib/models/captions.dart create mode 100644 lib/utils/multi_tap_recognizer.dart 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(), diff --git a/lib/main.dart b/lib/main.dart index a8c3a1d..4679e1b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ 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 +15,8 @@ import 'package:url_launcher/url_launcher_string.dart'; import 'app.dart'; void main() async { + WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); runApp(Main()); } diff --git a/lib/models/app.dart b/lib/models/app.dart index 8416f9c..5bf4795 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; @@ -41,7 +41,6 @@ class AppModel extends ChangeNotifier { Timer? _disconnectNotificationTimer; int synchThreshold = 2; - int _prefferedOrientation = 0; bool _isChatVisible = true; bool _showSubtitles = true; bool _hasSystemUi = false; @@ -50,6 +49,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) { @@ -503,20 +512,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..da2b6f1 --- /dev/null +++ b/lib/models/captions.dart @@ -0,0 +1,93 @@ +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; + } +} + +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/player.dart b/lib/models/player.dart index 49cf352..ac149eb 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,10 +14,12 @@ 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; @@ -42,14 +45,27 @@ class PlayerModel extends ChangeNotifier { notifyListeners(); } - PlayerModel(this.app, this.playlist); + PlayerModel(this.app, this.playlist) { + player = Player(); + controller = VideoController(player); + + 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()); + } 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 +89,30 @@ 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); } double getDuration() { @@ -131,13 +139,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 +148,24 @@ 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(() { + _loadCaptions(item)?.then((subs) { + _currentCaptions = subs; notifyListeners(); }); app.send( @@ -174,14 +179,21 @@ class PlayerModel extends ChangeNotifier { notifyListeners(); } + LocalClosedCaptionFile? _currentCaptions; + LocalClosedCaptionFile? get currentCaptions => _currentCaptions; + Future getVideoDuration(String url) async { - if (url.contains('youtu')) url = await getYoutubeVideoUrl(url); - final controller = VideoPlayerController.networkUrl(Uri.parse(url)); + 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()); } @@ -206,7 +218,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 +231,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,7 +366,7 @@ 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); @@ -343,7 +383,7 @@ class PlayerModel extends ChangeNotifier { return compute(_loadCaptionsFuture, subsUrl); } - static Future _loadYoutubeCaptionsFuture( + static Future _loadYoutubeCaptionsFuture( VideoList item, ) async { final subs = await getYoutubeSubtitles(item.url); @@ -351,7 +391,7 @@ class PlayerModel extends ChangeNotifier { 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 +485,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 +505,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, @@ -496,7 +536,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 +558,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..e32612b 100644 --- a/lib/video_player.dart +++ b/lib/video_player.dart @@ -1,10 +1,12 @@ 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 +25,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 +59,82 @@ 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 captionText = player.currentCaptions + ?.getCaptionFor(player.player.state.position) + ?.text; + return MultiTapListener( + // recognitionWindow: new Duration(milliseconds: 1000), + onDoubleTap: () => goLandscapeOrFullscreen(player.app), + // 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: [ + FittedBox( + fit: player.isFitWidth ? BoxFit.fitWidth : BoxFit.contain, + child: SizedBox( + width: player.player.state.width?.toDouble() ?? 1280, + height: player.player.state.height?.toDouble() ?? 720, + child: Video(controller: player.controller), + ), ), - ), - ], - ), - 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), + if (player.app.showSubtitles && + captionText != null && + captionText.isNotEmpty) + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 50), + child: Text( + captionText, + style: const TextStyle( + fontSize: 16, + color: Colors.white, + backgroundColor: Colors.black54, + ), + textAlign: TextAlign.center, + ), + ), + ), + _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), + ), ), ), - ), - ], + ], + ), ), ); } + 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 +181,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 +228,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, @@ -267,15 +275,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: 25, ), tooltip: 'Double-tap or long-tap for fullscreen', 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 +305,59 @@ class _PlayPauseOverlay extends StatelessWidget { ), ); } + + 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 { 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 From f2ce62371b065675c720e6fceeffcdac59b62861 Mon Sep 17 00:00:00 2001 From: RblSb Date: Sun, 22 Feb 2026 23:34:20 +0300 Subject: [PATCH 02/13] fix pause indicator --- lib/chat_panel.dart | 4 +-- lib/models/app.dart | 50 +++++++++++++++++++++++++++++--------- lib/models/chat_panel.dart | 11 ++------- lib/wsdata.dart | 10 +++++++- 4 files changed, 51 insertions(+), 24 deletions(-) diff --git a/lib/chat_panel.dart b/lib/chat_panel.dart index ede7a76..e954790 100644 --- a/lib/chat_panel.dart +++ b/lib/chat_panel.dart @@ -119,7 +119,7 @@ class ChatPanel extends StatelessWidget { } Widget _onlineButton(ChatPanelModel panel, BuildContext context) { - final isPlayerIconVisible = !panel.serverPlay || panel.hasLeader(); + final isPlayerIconVisible = panel.lastState.paused || panel.hasLeader(); return Row( children: [ Text( @@ -130,7 +130,7 @@ class ChatPanel extends StatelessWidget { ), if (isPlayerIconVisible) Icon( - panel.serverPlay ? Icons.play_arrow : Icons.pause, + panel.lastState.paused ? Icons.pause : Icons.play_arrow, color: Theme.of(context).icon, ) else diff --git a/lib/models/app.dart b/lib/models/app.dart index 5bf4795..6653b1b 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -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 = []; @@ -303,7 +319,6 @@ class AppModel extends ChangeNotifier { if (!player.isVideoLoaded()) return; if (playlist.isEmpty()) { player.pause(); - chatPanel.serverPlay = true; } break; case 'SkipVideo': @@ -318,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), @@ -325,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': @@ -351,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': @@ -360,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!; @@ -448,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; 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/wsdata.dart b/lib/wsdata.dart index ea1a7a8..689ce10 100644 --- a/lib/wsdata.dart +++ b/lib/wsdata.dart @@ -826,13 +826,20 @@ 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; + pausedByServer = json['pausedByServer'] ?? false; rate = json['rate'] ?? 1; } @@ -840,6 +847,7 @@ class GetTime { final Map data = new Map(); data['time'] = this.time; data['paused'] = this.paused; + data['pausedByServer'] = this.pausedByServer; data['rate'] = this.rate; return data; } From faf4747e08c6c8bdffc2292db83602e6f30a1da2 Mon Sep 17 00:00:00 2001 From: RblSb Date: Mon, 23 Feb 2026 00:12:38 +0300 Subject: [PATCH 03/13] play rate button --- lib/models/player.dart | 9 ++++++ lib/video_player.dart | 63 ++++++++++++++++++++++++++++++++++++++++++ lib/wsdata.dart | 4 +-- 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/lib/models/player.dart b/lib/models/player.dart index ac149eb..c5566f1 100644 --- a/lib/models/player.dart +++ b/lib/models/player.dart @@ -58,6 +58,7 @@ class PlayerModel extends ChangeNotifier { player.stream.position.listen((_) => notifyListeners()); player.stream.playing.listen((_) => notifyListeners()); player.stream.buffer.listen((_) => notifyListeners()); + player.stream.rate.listen((_) => notifyListeners()); } bool isVideoLoaded() { @@ -113,6 +114,14 @@ class PlayerModel extends ChangeNotifier { void setPlaybackSpeed(double rate) async { await player.setRate(rate); + notifyListeners(); + if (!app.isLeader()) return; + app.send( + WsData( + type: 'SetRate', + setRate: SetRate(rate: rate), + ), + ); } double getDuration() { diff --git a/lib/video_player.dart b/lib/video_player.dart index e32612b..f5dc7f6 100644 --- a/lib/video_player.dart +++ b/lib/video_player.dart @@ -248,6 +248,69 @@ class _PlayPauseOverlay extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ + 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); + }, + 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}x'), + ); + }, + ).toList(); + }, + ), IconButton( icon: Icon( player.isFitWidth diff --git a/lib/wsdata.dart b/lib/wsdata.dart index 689ce10..ac09528 100644 --- a/lib/wsdata.dart +++ b/lib/wsdata.dart @@ -840,7 +840,7 @@ class GetTime { time = json['time'].toDouble(); paused = json['paused'] ?? false; pausedByServer = json['pausedByServer'] ?? false; - rate = json['rate'] ?? 1; + rate = json['rate']?.toDouble() ?? 1; } Map toJson() { @@ -859,7 +859,7 @@ class SetRate { SetRate({required this.rate}); SetRate.fromJson(Map json) { - rate = json['rate']; + rate = json['rate'].toDouble(); } Map toJson() { From 78361218d7add1de9b8c2410d6abe762223dbe9d Mon Sep 17 00:00:00 2001 From: RblSb Date: Mon, 23 Feb 2026 00:37:35 +0300 Subject: [PATCH 04/13] toggle chat button --- lib/models/player.dart | 2 + lib/video_player.dart | 109 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/lib/models/player.dart b/lib/models/player.dart index c5566f1..18afa8f 100644 --- a/lib/models/player.dart +++ b/lib/models/player.dart @@ -189,6 +189,8 @@ class PlayerModel extends ChangeNotifier { } LocalClosedCaptionFile? _currentCaptions; + + String fullscreenTooltipText = 'Double-tap or long-tap to toggle fullscreen'; LocalClosedCaptionFile? get currentCaptions => _currentCaptions; Future getVideoDuration(String url) async { diff --git a/lib/video_player.dart b/lib/video_player.dart index f5dc7f6..a859fbf 100644 --- a/lib/video_player.dart +++ b/lib/video_player.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -106,17 +108,7 @@ class VideoPlayerScreen extends StatelessWidget { ), ), _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), - ), - ), - ), + _ChatToggleButton(player: player), ], ), ), @@ -340,9 +332,9 @@ class _PlayPauseOverlay extends StatelessWidget { icon: Icon( Icons.screen_rotation, color: Theme.of(context).playerIcon, - size: 25, + size: 23, ), - tooltip: 'Double-tap or long-tap for fullscreen', + tooltip: player.fullscreenTooltipText, onPressed: () { final orientation = MediaQuery.of(context).orientation; switch (orientation) { @@ -478,3 +470,94 @@ 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 == Orientation.portrait) { + return const SizedBox.shrink(); + } + + final app = player.app; + final bool showControls = player.showControls; + final bool showMessageIcon = player.showMessageIcon; + // final bool showMessageIcon = true; + + return AnimatedOpacity( + opacity: (showControls || showMessageIcon) ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + child: 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(); + }, + ) + : Padding( + padding: const EdgeInsets.all(12), + child: Icon( + Icons.chat_bubble_outline, + 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), + ), + ), + ), + ), + ], + ); + } +} From 57f1e8c212ced90f23428057412d7bba1829d7ef Mon Sep 17 00:00:00 2001 From: RblSb Date: Mon, 23 Feb 2026 01:57:23 +0300 Subject: [PATCH 05/13] input padding fix --- lib/chat.dart | 67 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/lib/chat.dart b/lib/chat.dart index 983da4e..9c86691 100644 --- a/lib/chat.dart +++ b/lib/chat.dart @@ -292,6 +292,9 @@ class _ChatState extends State { crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.max, children: [ + SizedBox( + width: 10, + ), Expanded( child: TextField( inputFormatters: [ @@ -308,6 +311,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), ), ), @@ -362,35 +375,37 @@ class _ChatState extends State { if (!chat.isUnknownClient) GestureDetector( onLongPress: () { + HapticFeedback.mediumImpact(); 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; + child: Padding( + padding: const EdgeInsets.all(5), + child: IconButton( + onPressed: () { + if (showRewindMenu) { + setState(() => showRewindMenu = false); + return; } - }); - 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, + 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, + ), ), ), ), From 20c60f80c7c014708d523e4fc011f0c54469e9fb Mon Sep 17 00:00:00 2001 From: RblSb Date: Mon, 23 Feb 2026 17:25:16 +0300 Subject: [PATCH 06/13] split things --- lib/video_player.dart | 224 ++++++++++++++++++++++-------------------- 1 file changed, 116 insertions(+), 108 deletions(-) diff --git a/lib/video_player.dart b/lib/video_player.dart index a859fbf..7667c8d 100644 --- a/lib/video_player.dart +++ b/lib/video_player.dart @@ -61,13 +61,12 @@ class VideoPlayerScreen extends StatelessWidget { Widget buildPlayer(PlayerModel player) { if (player.isIframe()) return iframeWidget(player); + final captionText = player.currentCaptions ?.getCaptionFor(player.player.state.position) ?.text; return MultiTapListener( - // recognitionWindow: new Duration(milliseconds: 1000), onDoubleTap: () => goLandscapeOrFullscreen(player.app), - // onDoubleTap: () => goLandscapeOrFullscreen(player.app), child: GestureDetector( // onDoubleTap: () => goLandscapeOrFullscreen(player.app), onLongPress: () { @@ -82,8 +81,8 @@ class VideoPlayerScreen extends StatelessWidget { FittedBox( fit: player.isFitWidth ? BoxFit.fitWidth : BoxFit.contain, child: SizedBox( - width: player.player.state.width?.toDouble() ?? 1280, - height: player.player.state.height?.toDouble() ?? 720, + width: player.player.state.width?.toDouble() ?? (1280 / 2), + height: player.player.state.height?.toDouble() ?? (720 / 2), child: Video(controller: player.controller), ), ), @@ -223,7 +222,7 @@ class _PlayPauseOverlay extends StatelessWidget { child: Text(_timeText(player)), ), ), - if (player.showControls) buildVideoProgress(context), + if (player.showControls) _buildVideoProgress(context), if (player.showControls) Align( alignment: Alignment.topLeft, @@ -240,69 +239,7 @@ class _PlayPauseOverlay extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - 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); - }, - 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}x'), - ); - }, - ).toList(); - }, - ), + _buildPlaybackRateButton(context), IconButton( icon: Icon( player.isFitWidth @@ -361,7 +298,74 @@ class _PlayPauseOverlay extends StatelessWidget { ); } - GestureDetector buildVideoProgress(BuildContext context) { + 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(); @@ -481,55 +485,59 @@ class _ChatToggleButton extends StatelessWidget { @override Widget build(BuildContext context) { - if (MediaQuery.of(context).orientation == Orientation.portrait) { + if (MediaQuery.of(context).orientation == .portrait) { return const SizedBox.shrink(); } final app = player.app; - final bool showControls = player.showControls; - final bool showMessageIcon = player.showMessageIcon; - // final bool showMessageIcon = true; + final showControls = player.showControls; + final showMessageIcon = player.showMessageIcon; + // final showMessageIcon = true; - return AnimatedOpacity( - opacity: (showControls || showMessageIcon) ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - child: 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(); - }, - ) - : Padding( - padding: const EdgeInsets.all(12), - child: Icon( - Icons.chat_bubble_outline, - color: Theme.of( - context, - ).playerIcon.withValues(alpha: 0.5), - size: 24, - shadows: [ - Shadow( - color: Color.fromRGBO(0, 0, 0, 0.1), - blurRadius: 10, - ), - ], - ), - ), - ), + 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; From 95dfb3df50e5e85b98698c69822e9f6a8c28ffaa Mon Sep 17 00:00:00 2001 From: RblSb Date: Mon, 23 Feb 2026 23:32:10 +0300 Subject: [PATCH 07/13] workaround for endless hls duration extraction this only fixes adding m3u8 videos to playlist, there's still massive buffering when playing some with many audiotracks. no solution see https://github.com/media-kit/media-kit/issues/1372 --- lib/models/player.dart | 77 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/lib/models/player.dart b/lib/models/player.dart index 18afa8f..5876250 100644 --- a/lib/models/player.dart +++ b/lib/models/player.dart @@ -47,7 +47,13 @@ class PlayerModel extends ChangeNotifier { PlayerModel(this.app, this.playlist) { player = Player(); - controller = VideoController(player); + controller = VideoController( + player, + // configuration: VideoControllerConfiguration( + // androidAttachSurfaceAfterVideoParameters: false, + // enableHardwareAcceleration: false, + // ), + ); player.stream.completed.listen((completed) { if (completed) { @@ -194,6 +200,18 @@ class PlayerModel extends ChangeNotifier { LocalClosedCaptionFile? get currentCaptions => _currentCaptions; Future getVideoDuration(String url) async { + // 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; } @@ -212,6 +230,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)!; From 880e51262ab4b53d925b086802ea74962783d1e1 Mon Sep 17 00:00:00 2001 From: RblSb Date: Tue, 24 Feb 2026 00:06:16 +0300 Subject: [PATCH 08/13] fix pause on split-screen --- lib/app.dart | 6 ++---- lib/video_player.dart | 6 +++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 5ca0557..d6529da 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -234,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/video_player.dart b/lib/video_player.dart index 7667c8d..7e8735d 100644 --- a/lib/video_player.dart +++ b/lib/video_player.dart @@ -83,7 +83,11 @@ class VideoPlayerScreen extends StatelessWidget { child: SizedBox( width: player.player.state.width?.toDouble() ?? (1280 / 2), height: player.player.state.height?.toDouble() ?? (720 / 2), - child: Video(controller: player.controller), + child: Video( + controller: player.controller, + pauseUponEnteringBackgroundMode: + !player.app.hasBackgroundAudio, + ), ), ), ], From 37a0c72b251baf7ffe3f2515c57738594150eb7d Mon Sep 17 00:00:00 2001 From: RblSb Date: Tue, 24 Feb 2026 00:31:30 +0300 Subject: [PATCH 09/13] redundant boxes --- lib/video_player.dart | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/video_player.dart b/lib/video_player.dart index 7e8735d..d1dfa7e 100644 --- a/lib/video_player.dart +++ b/lib/video_player.dart @@ -62,8 +62,12 @@ class VideoPlayerScreen extends StatelessWidget { Widget buildPlayer(PlayerModel player) { if (player.isIframe()) return iframeWidget(player); + final playerState = player.player.state; + final playerWidth = playerState.width?.toDouble() ?? (1280 / 2); + final playerHeight = playerState.height?.toDouble() ?? (1280 / 2); + final captionText = player.currentCaptions - ?.getCaptionFor(player.player.state.position) + ?.getCaptionFor(playerState.position) ?.text; return MultiTapListener( onDoubleTap: () => goLandscapeOrFullscreen(player.app), @@ -78,17 +82,13 @@ class VideoPlayerScreen extends StatelessWidget { Stack( fit: StackFit.expand, children: [ - FittedBox( + Video( + controller: player.controller, fit: player.isFitWidth ? BoxFit.fitWidth : BoxFit.contain, - child: SizedBox( - width: player.player.state.width?.toDouble() ?? (1280 / 2), - height: player.player.state.height?.toDouble() ?? (720 / 2), - child: Video( - controller: player.controller, - pauseUponEnteringBackgroundMode: - !player.app.hasBackgroundAudio, - ), - ), + width: playerWidth, + height: playerHeight, + pauseUponEnteringBackgroundMode: + !player.app.hasBackgroundAudio, ), ], ), From 5d5a12bb7096fb88f7ba864690014bf7446502fc Mon Sep 17 00:00:00 2001 From: RblSb Date: Tue, 24 Feb 2026 01:25:24 +0300 Subject: [PATCH 10/13] fix overflow in tiny viewport --- lib/chat_panel.dart | 52 +++++++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/lib/chat_panel.dart b/lib/chat_panel.dart index e954790..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( @@ -120,22 +114,34 @@ class ChatPanel extends StatelessWidget { Widget _onlineButton(ChatPanelModel panel, BuildContext context) { final isPlayerIconVisible = panel.lastState.paused || panel.hasLeader(); - return Row( - children: [ - Text( - !panel.isConnected - ? 'Connection...' - : '${panel.clients.length} online', - style: TextStyle(color: Theme.of(context).icon), + 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.lastState.paused ? Icons.pause : Icons.play_arrow, - 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, + ), + ], + ), ); } } From e21206535d59370edfd9c36ded214aabd1bf01eb Mon Sep 17 00:00:00 2001 From: RblSb Date: Tue, 24 Feb 2026 02:16:08 +0300 Subject: [PATCH 11/13] fix subs --- lib/models/captions.dart | 25 +++++++++++++++++++++++++ lib/models/player.dart | 22 +++++++++++++--------- lib/video_player.dart | 25 ++++--------------------- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/lib/models/captions.dart b/lib/models/captions.dart index da2b6f1..fa6e4c5 100644 --- a/lib/models/captions.dart +++ b/lib/models/captions.dart @@ -24,6 +24,31 @@ abstract class LocalClosedCaptionFile { } 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 { diff --git a/lib/models/player.dart b/lib/models/player.dart index 5876250..5042b6a 100644 --- a/lib/models/player.dart +++ b/lib/models/player.dart @@ -25,6 +25,7 @@ class PlayerModel extends ChangeNotifier { final PlaylistModel playlist; bool showControls = false; + bool hasSubtitleTrack = false; Timer? controlsTimer; bool _showMessageIcon = false; @@ -179,8 +180,15 @@ class PlayerModel extends ChangeNotifier { }(); initPlayerFuture?.whenComplete(() { + hasSubtitleTrack = false; _loadCaptions(item)?.then((subs) { - _currentCaptions = subs; + var srt = subs.toSRT(); + hasSubtitleTrack = srt.isNotEmpty; + player.setSubtitleTrack( + SubtitleTrack.data( + srt, + ), + ); notifyListeners(); }); app.send( @@ -194,10 +202,7 @@ class PlayerModel extends ChangeNotifier { notifyListeners(); } - LocalClosedCaptionFile? _currentCaptions; - String fullscreenTooltipText = 'Double-tap or long-tap to toggle fullscreen'; - LocalClosedCaptionFile? get currentCaptions => _currentCaptions; Future getVideoDuration(String url) async { // mediakit will parse segment durations for a minute while loading them, @@ -454,8 +459,7 @@ class PlayerModel extends ChangeNotifier { 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; @@ -470,10 +474,9 @@ class PlayerModel extends ChangeNotifier { } static Future _loadYoutubeCaptionsFuture( - VideoList item, + String url, ) async { - final subs = await getYoutubeSubtitles(item.url); - if (subs.captions.isEmpty) item.subs = ''; + final subs = await getYoutubeSubtitles(url); return subs; } @@ -609,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; diff --git a/lib/video_player.dart b/lib/video_player.dart index d1dfa7e..313de2a 100644 --- a/lib/video_player.dart +++ b/lib/video_player.dart @@ -66,9 +66,6 @@ class VideoPlayerScreen extends StatelessWidget { final playerWidth = playerState.width?.toDouble() ?? (1280 / 2); final playerHeight = playerState.height?.toDouble() ?? (1280 / 2); - final captionText = player.currentCaptions - ?.getCaptionFor(playerState.position) - ?.text; return MultiTapListener( onDoubleTap: () => goLandscapeOrFullscreen(player.app), child: GestureDetector( @@ -89,27 +86,13 @@ class VideoPlayerScreen extends StatelessWidget { height: playerHeight, pauseUponEnteringBackgroundMode: !player.app.hasBackgroundAudio, + subtitleViewConfiguration: SubtitleViewConfiguration( + textScaler: .linear(0.55), + visible: player.app.showSubtitles, + ), ), ], ), - if (player.app.showSubtitles && - captionText != null && - captionText.isNotEmpty) - Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.only(bottom: 50), - child: Text( - captionText, - style: const TextStyle( - fontSize: 16, - color: Colors.white, - backgroundColor: Colors.black54, - ), - textAlign: TextAlign.center, - ), - ), - ), _PlayPauseOverlay(player: player), _ChatToggleButton(player: player), ], From 7a42b49d9fca1960888295b8a9782d8f562033f2 Mon Sep 17 00:00:00 2001 From: RblSb Date: Tue, 24 Feb 2026 20:20:51 +0300 Subject: [PATCH 12/13] Split builds --- .github/workflows/main.yml | 14 ++++++++++---- lib/main.dart | 21 +++++++++++++++++++-- 2 files changed, 29 insertions(+), 6 deletions(-) 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/lib/main.dart b/lib/main.dart index 4679e1b..af38e88 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:app_links/app_links.dart'; import 'package:device_info_plus/device_info_plus.dart'; @@ -66,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 @@ -78,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/']; } @@ -346,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, From 6f61f4105b946394e3fbc2f059df314148215926 Mon Sep 17 00:00:00 2001 From: RblSb Date: Tue, 24 Feb 2026 20:29:45 +0300 Subject: [PATCH 13/13] guest input padding fix --- lib/chat.dart | 143 ++++++++++++++++++++++++++------------------------ 1 file changed, 74 insertions(+), 69 deletions(-) diff --git a/lib/chat.dart b/lib/chat.dart index 9c86691..ff85cd7 100644 --- a/lib/chat.dart +++ b/lib/chat.dart @@ -257,44 +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, - ), + SizedBox(width: 10), Expanded( child: TextField( inputFormatters: [ @@ -372,43 +342,9 @@ class _ChatState extends State { }, ), ), - if (!chat.isUnknownClient) - 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, - ), - ), - ), - ), + chat.isUnknownClient + ? SizedBox(width: 10) + : _buildEmotesButton(context), ], ), if (showEmotesTab) @@ -419,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, + ), + ), + ), + ); + } }