From 5df39a675cd20ddee37febd2f046e7e425cf8978 Mon Sep 17 00:00:00 2001 From: RblSb Date: Wed, 11 Feb 2026 08:54:17 +0300 Subject: [PATCH 1/2] Fix some urls and improve srt parsing --- android/settings.gradle.kts | 2 +- lib/app.dart | 1 + lib/models/app.dart | 1 + lib/models/player.dart | 37 +++++++++----- pubspec.lock | 96 ++++++++++++++++++------------------- pubspec.yaml | 24 +++------- 6 files changed, 83 insertions(+), 78 deletions(-) diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ab39a10..43394ed 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,7 +18,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.3" apply false + id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false } diff --git a/lib/app.dart b/lib/app.dart index 7a70c72..5f8be62 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -378,6 +378,7 @@ class _AppState extends State with WidgetsBindingObserver { } if (clipboardText.endsWith('.ts')) { url = clipboardText.replaceAll('240.mp4', '1080.mp4'); + url = clipboardText.replaceAll('360.mp4', '1080.mp4'); // `1080.mp4:hls:seg-123-v1-a1.ts` => `1080.mp4:hls:manifest.m3u8` url = url.replaceAll(RegExp(r'seg-[^:]+\.ts$'), 'manifest.m3u8'); } diff --git a/lib/models/app.dart b/lib/models/app.dart index 169c894..8416f9c 100644 --- a/lib/models/app.dart +++ b/lib/models/app.dart @@ -525,6 +525,7 @@ class AppModel extends ChangeNotifier { if (!data.item.url.startsWith('http')) { data.item.url = 'http://${data.item.url}'; } + data.item.url = Uri.encodeFull(data.item.url); final url = data.item.url; if (url.contains('youtube.com/playlist')) { sendYoutubePlaylist(data); diff --git a/lib/models/player.dart b/lib/models/player.dart index 3fc8342..49cf352 100644 --- a/lib/models/player.dart +++ b/lib/models/player.dart @@ -333,15 +333,13 @@ class PlayerModel extends ChangeNotifier { } var subsUrl = item.subs ?? ''; if (subsUrl.isEmpty) return null; + if (subsUrl.startsWith('/')) { + final relativeHost = app.getChannelLink(); + subsUrl = '$relativeHost${subsUrl}'; + } if (!subsUrl.startsWith('http')) { subsUrl = 'http://$subsUrl'; } - // if (subsUrl == '') { - // if (item.duration < 60 * 5) return null; - // final i = item.url.lastIndexOf('.mp4'); - // if (i == -1) return null; - // subsUrl = item.url.replaceFirst('.mp4', '.ass', i); - // } return compute(_loadCaptionsFuture, subsUrl); } @@ -382,6 +380,7 @@ class PlayerModel extends ChangeNotifier { final lines = text.replaceAll('\r\n', '\n').split('\n'); final blocks = getSrtBlocks(lines); final badTimeReg = RegExp(r'(,[\d]+)'); + for (final lines in blocks) { if (lines.length < 3) continue; final textLines = lines.getRange(2, lines.length).toList(); @@ -389,12 +388,28 @@ class PlayerModel extends ChangeNotifier { final ms = match.group(1)!; return ms.length < 4 ? ms.padRight(4, '0') : ms; }); - subs.add({ - 'counter': lines[0], - 'time': time.replaceAll(',', '.'), - 'text': textLines.join('\n').trim(), - }); + final normalizedTime = time.replaceAll(',', '.'); + + // find last index that has different timing. will be `length - 1` element most of the time + final lastDifferentTimeI = subs.lastIndexWhere( + (sub) => sub['time'] != normalizedTime, + ); + final i = lastDifferentTimeI + 1; + + final text = textLines.join('\n').trim(); + if (i < subs.length) { + // Merge text with existing subtitle (add at top like in web case) + subs[i]['text'] = '$text\n${subs[i]['text']}'; + } else { + // Add new subtitle + subs.add({ + 'counter': lines[0], + 'time': normalizedTime, + 'text': text, + }); + } } + var data = 'WEBVTT\n\n'; for (final sub in subs) { data += '${sub['counter']}\n'; diff --git a/pubspec.lock b/pubspec.lock index 5650836..c252682 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: "direct main" description: name: app_links - sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + sha256: "3462d9defc61565fde4944858b59bec5be2b9d5b05f20aed190adb3ad08a7abc" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "7.0.0" app_links_linux: dependency: transitive description: @@ -69,10 +69,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" clock: dependency: transitive description: @@ -93,10 +93,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" url: "https://pub.dev" source: hosted - version: "0.3.5" + version: "0.3.5+1" crypto: dependency: "direct main" description: @@ -125,10 +125,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: dd0e8e02186b2196c7848c9d394a5fd6e5b57a43a546082c5820b1ec72317e33 + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" url: "https://pub.dev" source: hosted - version: "12.2.0" + version: "12.3.0" device_info_plus_platform_interface: dependency: transitive description: @@ -165,10 +165,10 @@ packages: dependency: "direct main" description: name: file_picker - sha256: f8f4ea435f791ab1f817b4e338ed958cb3d04ba43d6736ffc39958d950754967 + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" url: "https://pub.dev" source: hosted - version: "10.3.6" + version: "10.3.10" flutter: dependency: "direct main" description: flutter @@ -178,10 +178,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687" + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 url: "https://pub.dev" source: hosted - version: "2.0.32" + version: "2.0.33" flutter_test: dependency: "direct dev" description: flutter @@ -276,18 +276,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: @@ -300,10 +300,10 @@ packages: dependency: "direct main" description: name: native_device_orientation - sha256: bc0bcccc79752048d2235c10545c5fd554a46035fe0a4a4534d1bb9d8bc85e6c + sha256: "711aabfd7c67396f6562437cba078d17291f8b7c454c2fbc9739c9d7141b041d" url: "https://pub.dev" source: hosted - version: "2.0.4" + version: "2.1.0" nested: dependency: transitive description: @@ -316,10 +316,10 @@ packages: dependency: "direct main" description: name: ota_update - sha256: e0872d082b9498f543a9b51f25001e2c77e5e3963abdc36e7c849606a283d287 + sha256: "1f4c7c3c4f306729a6c00b84435096ce2d8b28439013f7237173acc699b2abc8" url: "https://pub.dev" source: hosted - version: "7.0.2" + version: "7.1.0" package_info_plus: dependency: "direct main" description: @@ -412,26 +412,26 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "2.5.4" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" url: "https://pub.dev" source: hosted - version: "2.4.15" + version: "2.4.18" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.6" shared_preferences_linux: dependency: transitive description: @@ -521,10 +521,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.9" typed_data: dependency: transitive description: @@ -553,34 +553,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "5c8b6c2d89a78f5a1cca70a73d9d5f86c701b36b42f9c9dac7bad592113c28e9" + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" url: "https://pub.dev" source: hosted - version: "6.3.24" + version: "6.3.28" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "6b63f1441e4f653ae799166a72b50b1767321ecc263a57aadf825a7a2a5477d9" + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad url: "https://pub.dev" source: hosted - version: "6.3.5" + version: "6.3.6" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "3.2.2" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "8262208506252a3ed4ff5c0dc1e973d2c0e0ef337d0a074d35634da5d44397c9" + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" url: "https://pub.dev" source: hosted - version: "3.2.4" + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -601,10 +601,10 @@ packages: dependency: transitive description: name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" vector_math: dependency: transitive description: @@ -617,26 +617,26 @@ packages: dependency: "direct main" description: name: video_player - sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf" + sha256: "08bfba72e311d48219acad4e191b1f9c27ff8cf928f2c7234874592d9c9d7341" url: "https://pub.dev" source: hosted - version: "2.10.1" + version: "2.11.0" video_player_android: dependency: transitive description: name: video_player_android - sha256: cf768d02924b91e333e2bc1ff928528f57d686445874f383bafab12d0bdfc340 + sha256: ee4fd520b0cafa02e4a867a0f882092e727cdaa1a2d24762171e787f8a502b0a url: "https://pub.dev" source: hosted - version: "2.8.17" + version: "2.9.1" video_player_avfoundation: dependency: transitive description: name: video_player_avfoundation - sha256: "03fc6d07dba2499588d30887329b399c1fe2d68ce4b7fcff0db79f44a2603f69" + sha256: f93b93a3baa12ca0ff7d00ca8bc60c1ecd96865568a01ff0c18a99853ee201a5 url: "https://pub.dev" source: hosted - version: "2.8.6" + version: "2.9.3" video_player_platform_interface: dependency: transitive description: @@ -737,10 +737,10 @@ packages: dependency: "direct main" description: name: youtube_explode_dart - sha256: "947ba05e0c4f050743e480e7bca3575ff6427d86cc898c1a69f5e1d188cdc9e0" + sha256: "3d731d71df9901b1915bae806781df519cff32517e36db279f844ae619669e45" url: "https://pub.dev" source: hosted - version: "2.5.3" + version: "3.0.5" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml index 0c8b2d9..61d1977 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,21 +1,9 @@ name: synctube -description: A new Flutter project. +description: SyncTube Android client -# The following line prevents the package from being accidentally published to -# pub.dev using `pub publish`. This is preferred for private packages. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.0+32 +version: 1.0.0+34 environment: sdk: ^3.9.0 @@ -32,7 +20,7 @@ dependencies: shared_preferences: ^2.5.3 - video_player: ^2.10.1 + video_player: ^2.11.0 # perfect_volume_control: ^1.0.5 @@ -40,7 +28,7 @@ dependencies: http: ^1.6.0 - youtube_explode_dart: ^2.5.3 + youtube_explode_dart: ^3.0.5 url_launcher: ^6.3.2 @@ -52,11 +40,11 @@ dependencies: package_info_plus: ^9.0.0 - app_links: ^6.4.0 + app_links: ^7.0.0 ota_update: ^7.0.2 - file_picker: ^10.3.6 + file_picker: ^10.3.10 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. From 491f08db56aaf4bfa0bcbbc1022607b3308aeb14 Mon Sep 17 00:00:00 2001 From: RblSb Date: Fri, 20 Feb 2026 00:11:35 +0300 Subject: [PATCH 2/2] Trying to make more players under abstract one --- lib/app.dart | 5 +- lib/models/player.dart | 451 ++++++------------------------- lib/players/abstract_player.dart | 29 ++ lib/players/raw_player.dart | 226 ++++++++++++++++ lib/players/youtube_player.dart | 245 +++++++++++++++++ lib/video_player.dart | 167 +++++++++--- pubspec.lock | 97 +++++-- pubspec.yaml | 8 + 8 files changed, 805 insertions(+), 423 deletions(-) create mode 100644 lib/players/abstract_player.dart create mode 100644 lib/players/raw_player.dart create mode 100644 lib/players/youtube_player.dart diff --git a/lib/app.dart b/lib/app.dart index 5f8be62..7332dde 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -130,10 +130,9 @@ class _AppState extends State with WidgetsBindingObserver { builder: (context, isChatVisible, child) { return Selector( selector: (context, player) { - final isInit = - player.controller?.value.isInitialized ?? false; + final isInit = player.isVideoLoaded(); if (!isInit) return 16 / 9; - return player.controller?.value.aspectRatio ?? 16 / 9; + return player.player?.aspectRatio ?? 16 / 9; }, builder: (context, ratio, child) { final media = MediaQuery.of(context); diff --git a/lib/models/player.dart b/lib/models/player.dart index 49cf352..d4993e8 100644 --- a/lib/models/player.dart +++ b/lib/models/player.dart @@ -1,22 +1,30 @@ import 'dart:async'; -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:youtube_explode_dart/youtube_explode_dart.dart' as youtube; -import 'package:youtube_explode_dart/youtube_explode_dart.dart'; - -import '../chat.dart'; -import '../subs/ass.dart'; -import '../subs/raw.dart'; +import 'package:flutter/widgets.dart'; + +import '../players/abstract_player.dart'; +import '../players/raw_player.dart'; +import '../players/youtube_player.dart'; import '../wsdata.dart'; import './app.dart'; import './playlist.dart'; class PlayerModel extends ChangeNotifier { - VideoPlayerController? controller; + static String extractVideoId(String url) => + YoutubePlayerImpl.extractVideoId(url); + + Future<({double duration, String title})?> getYoutubeInfo(String url) async { + final yt = YoutubePlayerImpl(app, () {}); + final info = await yt.getYoutubeInfo(url); + yt.dispose(); + return info; + } + + AbstractPlayer? player; + late RawPlayer rawPlayer; + late List players; + Future? initPlayerFuture; final AppModel app; final PlaylistModel playlist; @@ -42,14 +50,19 @@ class PlayerModel extends ChangeNotifier { notifyListeners(); } - PlayerModel(this.app, this.playlist); + PlayerModel(this.app, this.playlist) { + rawPlayer = RawPlayer(app, notifyListeners); + players = [ + YoutubePlayerImpl(app, notifyListeners), + ]; + } bool isVideoLoaded() { - return controller?.value.isInitialized ?? false; + return player?.isVideoLoaded() ?? false; } bool isPlaying() { - return controller?.value.isPlaying ?? false; + return !(player?.isPaused() ?? true); } void toggleControls(bool flag) { @@ -73,38 +86,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 await player?.getPosition() ?? Duration.zero; } - void pause() async { - if (!isVideoLoaded()) return; - await controller?.pause(); + void pause() { + player?.pause(); } - void play() async { - if (!isVideoLoaded()) return; + void play() { if (app.isInBackground) { if (!app.hasBackgroundAudio) return; } - await controller?.play(); + player?.play(); } - void seekTo(Duration duration) async { - if (!isVideoLoaded()) return; - await controller?.seekTo(duration); + void seekTo(Duration duration) { + player?.seekTo(duration); } Future getPlaybackSpeed() async { - if (!isVideoLoaded()) return 1.0; - return controller?.value.playbackSpeed ?? 1.0; + return await player?.getPlaybackRate() ?? 1.0; } - void setPlaybackSpeed(double rate) async { - if (!isVideoLoaded()) return; - await controller?.setPlaybackSpeed(rate); + void setPlaybackSpeed(double rate) { + player?.setPlaybackRate(rate); } double getDuration() { @@ -125,361 +130,79 @@ class PlayerModel extends ChangeNotifier { return item.title; } + void setPlayer(AbstractPlayer newPlayer) { + if (player != newPlayer) { + player?.removeVideo(); + } + player = newPlayer; + } + + void setSupportedPlayer(String url, String playerType) { + AbstractPlayer? foundPlayer; + for (final p in players) { + if (p.isSupportedLink(url)) { + foundPlayer = p; + break; + } + } + + if (foundPlayer != null) { + setPlayer(foundPlayer); + } else { + setPlayer(rawPlayer); + } + } + void loadVideo(int pos) async { playlist.setPos(pos); - initPlayerFuture = null; final item = playlist.getItem(playlist.pos); if (item == null) return; + if (isIframe()) { - final old = controller; - controller = null; + setPlayer(rawPlayer); // Fallback or handle iframe specifically initPlayerFuture = Future.microtask(() => null); notifyListeners(); - initPlayerFuture?.whenComplete(() { - Future.delayed(const Duration(seconds: 1), () => old?.dispose()); - }); return; } - var url = item.url; - if (url.startsWith('/')) { - final relativeHost = app.getChannelLink(); - url = '$relativeHost${url}'; - } - if (url.contains('youtu')) url = await getYoutubeVideoUrl(url); - 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(); + + setSupportedPlayer(item.url, item.playerType); + + initPlayerFuture = player?.loadVideo(item); initPlayerFuture?.whenComplete(() { - prevController?.dispose(); - controller?.setClosedCaptionFile(_loadCaptions(item)).whenComplete(() { - notifyListeners(); - }); app.send( WsData( type: 'VideoLoaded', ), ); + notifyListeners(); }); - // app.chat.addItem(ChatItem('', 'VideoLoaded')) + _isFitWidth = false; notifyListeners(); } Future getVideoDuration(String url) async { - if (url.contains('youtu')) url = await getYoutubeVideoUrl(url); - final controller = VideoPlayerController.networkUrl(Uri.parse(url)); - Duration? duration; - try { - await controller.initialize(); - duration = controller.value.duration; - controller.dispose(); - } catch (e) { - print(e.toString()); - } - if (duration == null) return 0; - return duration.inMilliseconds / 1000; - } - - static String extractVideoId(String url) { - if (url.contains('youtu.be/')) { - return RegExp(r'youtu.be\/([A-z0-9_-]+)').firstMatch(url)!.group(1)!; - } - if (url.contains('youtube.com/embed/')) { - return RegExp(r'embed\/([A-z0-9_-]+)').firstMatch(url)!.group(1)!; - } - if (url.contains('youtube.com/shorts/')) { - return RegExp( - r'/youtube\.com\/shorts\/([A-z0-9_-]+)', - ).firstMatch(url)!.group(1)!; - } - final r = RegExp(r'v=([A-z0-9_-]+)'); - if (!r.hasMatch(url)) return ''; - return r.firstMatch(url)!.group(1)!; - } - - Future getYoutubeVideoUrl(String url) async { - final yt = youtube.YoutubeExplode(); - try { - final id = extractVideoId(url); - StreamManifest manifest; - try { - manifest = await yt.videos.streamsClient.getManifest( - id, - ytClients: [YoutubeApiClient.androidVr], - ); - } catch (e) { - print(e); - app.chat.addItem(ChatItem('', e.toString())); - return ''; + AbstractPlayer? targetPlayer; + for (final p in players) { + if (p.isSupportedLink(url)) { + targetPlayer = p; + break; } - yt.close(); - // final stream = manifest.muxed.withHighestBitrate(); - final qualities = manifest.muxed.getAllVideoQualities().toList(); - final values = youtube.VideoQuality.values; - qualities.sort((a, b) { - return values.indexOf(a).compareTo(values.indexOf(b)); - }); - while (values.indexOf(qualities.last) > - values.indexOf(youtube.VideoQuality.high1080)) { - qualities.removeLast(); - } - // print(qualities); - final stream = manifest.muxed.firstWhere((element) { - return element.videoQuality == qualities.last; - }); - final streamUrl = stream.url.toString(); - return streamUrl; - } catch (e) { - print('getYoutubeVideoUrl error for url $url'); - print(e); - yt.close(); - return ''; } + targetPlayer ??= rawPlayer; + return await targetPlayer.getVideoDuration(url); } Future getVideoTitle(String url) async { - if (url.contains('youtu')) return getYoutubeVideoTitle(url); - - final matchName = RegExp(r'^(.+)\.(.+)'); - final decodedUrl = Uri.decodeFull(url); - var title = decodedUrl.substring(decodedUrl.lastIndexOf('/') + 1); - final isNameMatched = matchName.hasMatch(title); - if (isNameMatched) - title = matchName.stringMatch(title)!; - else - title = 'Raw Video'; - return Future.value(title); - } - - Future getYoutubeVideoTitle(String url) async { - final yt = youtube.YoutubeExplode(); - try { - final id = extractVideoId(url); - final manifest = await yt.videos.get(id); - yt.close(); - return manifest.title; - } catch (e) { - yt.close(); - return 'Youtube Video'; - } - } - - Future<({double duration, String title})?> getYoutubeInfo(String url) async { - try { - final videoId = PlayerModel.extractVideoId(url); - if (videoId.isEmpty) return null; - - final apiKey = app.config!.youtubeApiKey; - - final response = await http.get( - Uri.parse( - 'https://www.googleapis.com/youtube/v3/videos' - '?part=snippet,contentDetails' - '&fields=items(snippet/title,contentDetails/duration)' - '&id=$videoId' - '&key=$apiKey', - ), - ); - - if (response.statusCode != 200) return null; - - final data = jsonDecode(response.body) as Map; - final items = data['items'] as List?; - if (items == null || items.isEmpty) return null; - - final item = items.first as Map; - final title = (item['snippet'] as Map)['title'] as String? ?? 'Raw Video'; - final duration = _convertYoutubeDuration( - (item['contentDetails'] as Map)['duration'] as String? ?? '', - ); - - return (duration: duration, title: title); - } catch (_) { - return null; - } - } - - double _convertYoutubeDuration(String duration) { - final hoursMatch = RegExp(r'(\d+)H').firstMatch(duration); - final minutesMatch = RegExp(r'(\d+)M').firstMatch(duration); - final secondsMatch = RegExp(r'(\d+)S').firstMatch(duration); - - final hours = hoursMatch != null ? int.parse(hoursMatch.group(1)!) : 0; - final minutes = minutesMatch != null - ? int.parse(minutesMatch.group(1)!) - : 0; - final seconds = secondsMatch != null - ? int.parse(secondsMatch.group(1)!) - : 0; - - final total = hours * 3600 + minutes * 60 + seconds; - // 99 hours for live streams - return total == 0 ? 356400.0 : total.toDouble(); - } - - Future? _loadCaptions(VideoList item) { - if (item.url.contains('youtu')) { - item.subs = item.url; - return compute(_loadYoutubeCaptionsFuture, item); - } - var subsUrl = item.subs ?? ''; - if (subsUrl.isEmpty) return null; - if (subsUrl.startsWith('/')) { - final relativeHost = app.getChannelLink(); - subsUrl = '$relativeHost${subsUrl}'; - } - if (!subsUrl.startsWith('http')) { - subsUrl = 'http://$subsUrl'; - } - return compute(_loadCaptionsFuture, subsUrl); - } - - static Future _loadYoutubeCaptionsFuture( - VideoList item, - ) async { - final subs = await getYoutubeSubtitles(item.url); - if (subs.captions.isEmpty) item.subs = ''; - return subs; - } - - static Future _loadCaptionsFuture(String url) async { - Response response; - try { - response = await http.get(Uri.parse(url)).timeout(Duration(seconds: 5)); - } catch (_) { - print('Subtitles loading error ($url)'); - return RawCaptionFile([]); - } - if (response.statusCode == 200) { - var data = utf8.decode(response.bodyBytes); - if (url.endsWith('.srt')) { - data = parseSrt(data); - return WebVTTCaptionFile(data); - // return SubRipCaptionFile(data); - } else if (url.endsWith('.vtt')) { - return WebVTTCaptionFile(data); - } else { - return AssCaptionFile(data); - } - } else { - return RawCaptionFile([]); - } - } - - static String parseSrt(String text) { - final subs = >[]; - final lines = text.replaceAll('\r\n', '\n').split('\n'); - final blocks = getSrtBlocks(lines); - final badTimeReg = RegExp(r'(,[\d]+)'); - - for (final lines in blocks) { - if (lines.length < 3) continue; - final textLines = lines.getRange(2, lines.length).toList(); - final time = lines[1].replaceAllMapped(badTimeReg, (match) { - final ms = match.group(1)!; - return ms.length < 4 ? ms.padRight(4, '0') : ms; - }); - final normalizedTime = time.replaceAll(',', '.'); - - // find last index that has different timing. will be `length - 1` element most of the time - final lastDifferentTimeI = subs.lastIndexWhere( - (sub) => sub['time'] != normalizedTime, - ); - final i = lastDifferentTimeI + 1; - - final text = textLines.join('\n').trim(); - if (i < subs.length) { - // Merge text with existing subtitle (add at top like in web case) - subs[i]['text'] = '$text\n${subs[i]['text']}'; - } else { - // Add new subtitle - subs.add({ - 'counter': lines[0], - 'time': normalizedTime, - 'text': text, - }); - } - } - - var data = 'WEBVTT\n\n'; - for (final sub in subs) { - data += '${sub['counter']}\n'; - data += '${sub['time']}\n'; - data += '${sub['text']}\n\n'; - } - return data; - } - - static List> getSrtBlocks(List lines) { - final blocks = >[]; - final isNumLineReg = RegExp(r'^(\d+)$'); - // [id, time, firstTextLine, ... lastTextLine] - var block = []; - for (var i = 0; i < lines.length; i++) { - final line = lines[i]; - if (blocks.isEmpty && line.isEmpty) continue; - final prevLine = i > 0 ? lines[i - 1] : ''; - final nextLine = i < lines.length - 1 ? lines[i + 1] : ''; - // block id line - if (prevLine.isEmpty && - isNumLineReg.hasMatch(line) && - nextLine.contains('-->')) { - // push previously collected block and start new one - if (block.isNotEmpty) { - blocks.add(block); - block = []; - } - } - block.add(line); - } - if (block.isNotEmpty) blocks.add(block); - return blocks; - } - - static Future getYoutubeSubtitles(String url) async { - final yt = youtube.YoutubeExplode(); - try { - final id = extractVideoId(url); - final manifest = await yt.videos.closedCaptions.getManifest(id); - final en = manifest.getByLanguage('en', autoGenerated: false); - if (en.isEmpty) { - yt.close(); - return RawCaptionFile([]); + AbstractPlayer? targetPlayer; + for (final p in players) { + if (p.isSupportedLink(url)) { + targetPlayer = p; + break; } - final info = en.first; - youtube.ClosedCaptionTrack track; - try { - track = await yt.videos.closedCaptions.get(info); - } catch (e) { - yt.close(); - return RawCaptionFile([]); - } - var i = 0; - final items = track.captions.map((e) { - final caption = Caption( - number: i, - start: e.offset, - end: e.offset + e.duration, - text: e.text, - ); - i++; - return caption; - }).toList(); - yt.close(); - return RawCaptionFile(items); - } catch (e) { - yt.close(); - return RawCaptionFile([]); } + targetPlayer ??= rawPlayer; + return await targetPlayer.getVideoTitle(url); } bool hasCaptions() { @@ -496,7 +219,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 = await getPosition(); final time = posD.inMilliseconds / 1000; if (state) { app.send( @@ -516,10 +239,12 @@ class PlayerModel extends ChangeNotifier { } @override - void dispose() async { + void dispose() { print('PlayerModel disposed'); - await controller?.dispose(); - controller = null; + rawPlayer.dispose(); + for (final p in players) { + p.dispose(); + } super.dispose(); } } diff --git a/lib/players/abstract_player.dart b/lib/players/abstract_player.dart new file mode 100644 index 0000000..35a004c --- /dev/null +++ b/lib/players/abstract_player.dart @@ -0,0 +1,29 @@ +import 'package:flutter/widgets.dart'; +import '../wsdata.dart'; + +abstract class AbstractPlayer { + Widget build(BuildContext context); + String getPlayerType(); + bool isSupportedLink(String url); + Future loadVideo(VideoList item); + void removeVideo(); + bool isVideoLoaded(); + void play(); + void pause(); + bool isPaused(); + Future getPosition(); + void seekTo(Duration duration); + Future getPlaybackRate(); + void setPlaybackRate(double rate); + void setVolume(double volume); + + // For UI and logic + double get aspectRatio; + String get captionText; + + // For metadata + Future getVideoDuration(String url); + Future getVideoTitle(String url); + + void dispose(); +} diff --git a/lib/players/raw_player.dart b/lib/players/raw_player.dart new file mode 100644 index 0000000..a096ebe --- /dev/null +++ b/lib/players/raw_player.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:http/http.dart' as http; +import 'package:video_player/video_player.dart'; + +import '../models/app.dart'; +import '../subs/ass.dart'; +import '../subs/raw.dart'; +import '../wsdata.dart'; +import 'abstract_player.dart'; + +class RawPlayer extends AbstractPlayer { + VideoPlayerController? controller; + final AppModel app; + final Function() onNotify; + + RawPlayer(this.app, this.onNotify); + + @override + String getPlayerType() => 'RawType'; + + @override + bool isSupportedLink(String url) { + return false; + } + + @override + Future loadVideo(VideoList item) async { + var url = item.url; + if (url.startsWith('/')) { + final relativeHost = app.getChannelLink(); + url = '$relativeHost${url}'; + } + + final prevController = controller; + controller = VideoPlayerController.networkUrl( + Uri.parse(url), + videoPlayerOptions: VideoPlayerOptions( + mixWithOthers: true, + allowBackgroundPlayback: true, + ), + ); + controller!.addListener(onNotify); + await controller!.initialize(); + prevController?.dispose(); + + final captions = await _loadCaptions(item); + if (captions != null) { + await controller!.setClosedCaptionFile(Future.value(captions)); + } + } + + @override + void removeVideo() { + controller?.dispose(); + controller = null; + } + + @override + bool isVideoLoaded() => controller?.value.isInitialized ?? false; + + @override + void play() => controller?.play(); + + @override + void pause() => controller?.pause(); + + @override + bool isPaused() => !(controller?.value.isPlaying ?? false); + + @override + Future getPosition() async { + return await controller?.position ?? Duration.zero; + } + + @override + void seekTo(Duration duration) => controller?.seekTo(duration); + + @override + Future getPlaybackRate() async => + controller?.value.playbackSpeed ?? 1.0; + + @override + void setPlaybackRate(double rate) => controller?.setPlaybackSpeed(rate); + + @override + void setVolume(double volume) => controller?.setVolume(volume); + + @override + double get aspectRatio => controller?.value.aspectRatio ?? 16 / 9; + + @override + String get captionText => controller?.value.caption.text ?? ''; + + @override + Future getVideoDuration(String url) async { + final controller = VideoPlayerController.networkUrl(Uri.parse(url)); + try { + await controller.initialize(); + final duration = controller.value.duration.inMilliseconds / 1000; + await controller.dispose(); + return duration; + } catch (e) { + print(e); + return 0; + } + } + + @override + Future getVideoTitle(String url) async { + final matchName = RegExp(r'^(.+)\.(.+)'); + final decodedUrl = Uri.decodeFull(url); + var title = decodedUrl.substring(decodedUrl.lastIndexOf('/') + 1); + final isNameMatched = matchName.hasMatch(title); + if (isNameMatched) + title = matchName.stringMatch(title)!; + else + title = 'Raw Video'; + return title; + } + + @override + Widget build(BuildContext context) { + if (controller == null) return Container(); + return VideoPlayer(controller!); + } + + @override + void dispose() { + controller?.removeListener(onNotify); + controller?.dispose(); + } + + Future _loadCaptions(VideoList item) async { + var subsUrl = item.subs ?? ''; + if (subsUrl.isEmpty) return null; + if (subsUrl.startsWith('/')) { + final relativeHost = app.getChannelLink(); + subsUrl = '$relativeHost${subsUrl}'; + } + if (!subsUrl.startsWith('http')) { + subsUrl = 'http://$subsUrl'; + } + return compute(_loadCaptionsFuture, subsUrl); + } + + static Future _loadCaptionsFuture(String url) async { + http.Response response; + try { + response = await http.get(Uri.parse(url)).timeout(Duration(seconds: 5)); + } catch (_) { + return RawCaptionFile([]); + } + if (response.statusCode == 200) { + var data = utf8.decode(response.bodyBytes); + if (url.endsWith('.srt')) { + data = _parseSrt(data); + return WebVTTCaptionFile(data); + } else if (url.endsWith('.vtt')) { + return WebVTTCaptionFile(data); + } else { + return AssCaptionFile(data); + } + } else { + return RawCaptionFile([]); + } + } + + static String _parseSrt(String text) { + final subs = >[]; + final lines = text.replaceAll('\r\n', '\n').split('\n'); + final blocks = _getSrtBlocks(lines); + final badTimeReg = RegExp(r'(,[\d]+)'); + + for (final lines in blocks) { + if (lines.length < 3) continue; + final textLines = lines.getRange(2, lines.length).toList(); + final time = lines[1].replaceAllMapped(badTimeReg, (match) { + final ms = match.group(1)!; + return ms.length < 4 ? ms.padRight(4, '0') : ms; + }); + final normalizedTime = time.replaceAll(',', '.'); + + final text = textLines.join('\n').trim(); + subs.add({ + 'counter': lines[0], + 'time': normalizedTime, + 'text': text, + }); + } + + var data = 'WEBVTT\n\n'; + for (final sub in subs) { + data += '${sub['counter']}\n'; + data += '${sub['time']}\n'; + data += '${sub['text']}\n\n'; + } + return data; + } + + static List> _getSrtBlocks(List lines) { + final blocks = >[]; + final isNumLineReg = RegExp(r'^(\d+)$'); + var block = []; + for (var i = 0; i < lines.length; i++) { + final line = lines[i]; + if (blocks.isEmpty && line.isEmpty) continue; + final prevLine = i > 0 ? lines[i - 1] : ''; + final nextLine = i < lines.length - 1 ? lines[i + 1] : ''; + if (prevLine.isEmpty && + isNumLineReg.hasMatch(line) && + nextLine.contains('-->')) { + if (block.isNotEmpty) { + blocks.add(block); + block = []; + } + } + block.add(line); + } + if (block.isNotEmpty) blocks.add(block); + return blocks; + } +} diff --git a/lib/players/youtube_player.dart b/lib/players/youtube_player.dart new file mode 100644 index 0000000..d0188c3 --- /dev/null +++ b/lib/players/youtube_player.dart @@ -0,0 +1,245 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; +import 'package:http/http.dart' as http; +import 'package:youtube_player_iframe/youtube_player_iframe.dart'; + +import '../models/app.dart'; +import '../wsdata.dart'; +import 'abstract_player.dart'; + +class YoutubePlayerImpl extends AbstractPlayer { + YoutubePlayerController? _controller; + StreamSubscription? _subscription; + final AppModel app; + final Function() onNotify; + + YoutubePlayerImpl(this.app, this.onNotify); + + @override + String getPlayerType() => 'YoutubeType'; + + @override + bool isSupportedLink(String url) { + return url.contains('youtu.be/') || + url.contains('youtube.com/watch') || + url.contains('youtube.com/embed/') || + url.contains('youtube.com/shorts/'); + } + + PlayerState? _lastState; + + @override + Future loadVideo(VideoList item) async { + final videoId = extractVideoId(item.url); + + if (_controller != null) { + dispose(); + _controller = null; + } + if (_controller == null) { + _controller = YoutubePlayerController.fromVideoId( + videoId: videoId, + // autoPlay: true, + params: const YoutubePlayerParams( + showControls: false, + showFullscreenButton: false, + mute: false, + origin: "https://www.youtube-nocookie.com", + ), + ); + _subscription = _controller!.videoStateStream.listen((state) { + final newState = _controller!.value.playerState; + if (newState == PlayerState.playing || + newState == PlayerState.paused || + newState == PlayerState.buffering || + newState == PlayerState.ended) { + _lastState = newState; + } + onNotify(); + }); + _controller!.cueVideoById(videoId: videoId); + } else { + _controller!.cueVideoById(videoId: videoId); + } + _lastState = PlayerState.cued; + onNotify(); + } + + @override + void removeVideo() { + _subscription?.cancel(); + _subscription = null; + _controller?.close(); + _controller = null; + _lastState = null; + } + + @override + bool isVideoLoaded() => _controller != null; + + @override + void play() { + _lastState = PlayerState.playing; + _controller?.playVideo(); + onNotify(); + } + + @override + void pause() { + _lastState = PlayerState.paused; + _controller?.pauseVideo(); + onNotify(); + } + + @override + bool isPaused() { + final state = _lastState ?? _controller?.value.playerState; + if (state == PlayerState.playing || state == PlayerState.buffering) { + return false; + } + if (state == PlayerState.paused || state == PlayerState.ended) { + return true; + } + // For transitional states (cued, unstarted, unknown), trust the room state + // if we don't have a clear manual intent. + if (_lastState == PlayerState.playing) return false; + if (_lastState == PlayerState.paused) return true; + + return !app.chatPanel.serverPlay; + } + + @override + Future getPosition() async { + if (_controller == null) return Duration.zero; + final seconds = await _controller!.currentTime; + return Duration(milliseconds: (seconds * 1000).toInt()); + } + + @override + void seekTo(Duration duration) { + _controller?.seekTo( + seconds: duration.inMilliseconds / 1000, + allowSeekAhead: true, + ); + } + + @override + Future getPlaybackRate() async { + return 1.0; + } + + @override + void setPlaybackRate(double rate) { + _controller?.setPlaybackRate(rate); + } + + @override + void setVolume(double volume) { + _controller?.setVolume((volume * 100).toInt()); + } + + @override + double get aspectRatio => 16 / 9; + + @override + String get captionText => ''; + + @override + Future getVideoDuration(String url) async { + final info = await getYoutubeInfo(url); + return info?.duration ?? 0; + } + + @override + Future getVideoTitle(String url) async { + final info = await getYoutubeInfo(url); + return info?.title ?? 'Youtube Video'; + } + + @override + Widget build(BuildContext context) { + if (_controller == null) return Container(); + return YoutubePlayer( + controller: _controller!, + ); + } + + @override + void dispose() { + _subscription?.cancel(); + _controller?.close(); + } + + static String extractVideoId(String url) { + if (url.contains('youtu.be/')) { + final match = RegExp(r'youtu.be\/([A-z0-9_-]+)').firstMatch(url); + if (match != null && match.groupCount >= 1) return match.group(1)!; + } + if (url.contains('youtube.com/embed/')) { + final match = RegExp(r'embed\/([A-z0-9_-]+)').firstMatch(url); + if (match != null && match.groupCount >= 1) return match.group(1)!; + } + if (url.contains('youtube.com/shorts/')) { + final match = RegExp(r'\/shorts\/([A-z0-9_-]+)').firstMatch(url); + if (match != null && match.groupCount >= 1) return match.group(1)!; + } + final r = RegExp(r'[?&]v=([A-z0-9_-]+)'); + final match = r.firstMatch(url); + if (match != null && match.groupCount >= 1) return match.group(1)!; + return ''; + } + + Future<({double duration, String title})?> getYoutubeInfo(String url) async { + try { + final videoId = extractVideoId(url); + if (videoId.isEmpty) return null; + + final apiKey = app.config!.youtubeApiKey; + final response = await http.get( + Uri.parse( + 'https://www.googleapis.com/youtube/v3/videos' + '?part=snippet,contentDetails' + '&fields=items(snippet/title,contentDetails/duration)' + '&id=$videoId' + '&key=$apiKey', + ), + ); + + if (response.statusCode != 200) return null; + + final data = jsonDecode(response.body) as Map; + final items = data['items'] as List?; + if (items == null || items.isEmpty) return null; + + final item = items.first as Map; + final title = + (item['snippet'] as Map)['title'] as String? ?? 'Youtube Video'; + final duration = _convertYoutubeDuration( + (item['contentDetails'] as Map)['duration'] as String? ?? '', + ); + + return (duration: duration, title: title); + } catch (_) { + return null; + } + } + + double _convertYoutubeDuration(String duration) { + final hoursMatch = RegExp(r'(\d+)H').firstMatch(duration); + final minutesMatch = RegExp(r'(\d+)M').firstMatch(duration); + final secondsMatch = RegExp(r'(\d+)S').firstMatch(duration); + + final hours = hoursMatch != null ? int.parse(hoursMatch.group(1)!) : 0; + final minutes = minutesMatch != null + ? int.parse(minutesMatch.group(1)!) + : 0; + final seconds = secondsMatch != null + ? int.parse(secondsMatch.group(1)!) + : 0; + + final total = hours * 3600 + minutes * 60 + seconds; + return total == 0 ? 356400.0 : total.toDouble(); + } +} diff --git a/lib/video_player.dart b/lib/video_player.dart index cbf87ff..4e50af0 100644 --- a/lib/video_player.dart +++ b/lib/video_player.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -10,6 +12,7 @@ import 'chat_panel.dart'; import 'color_scheme.dart'; import 'models/chat_panel.dart'; import 'models/player.dart'; +import 'players/raw_player.dart'; import 'settings.dart'; class VideoPlayerScreen extends StatelessWidget { @@ -30,10 +33,10 @@ class VideoPlayerScreen extends StatelessWidget { case ConnectionState.done: if (Settings.isTV) { return TvControls( - child: buildPlayer(player), + child: buildPlayer(context, player), ); } - return buildPlayer(player); + return buildPlayer(context, player); default: return GestureDetector( child: Settings.isTV @@ -56,9 +59,9 @@ class VideoPlayerScreen extends StatelessWidget { ); } - Widget buildPlayer(PlayerModel player) { + Widget buildPlayer(BuildContext context, PlayerModel player) { if (player.isIframe()) return iframeWidget(player); - final captionText = player.controller?.value.caption.text; + final captionText = player.player?.captionText; return GestureDetector( onDoubleTap: () => Settings.nextOrientationView(player.app), onLongPress: () { @@ -70,12 +73,14 @@ class VideoPlayerScreen extends StatelessWidget { 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!), + Center( + child: FittedBox( + fit: player.isFitWidth ? BoxFit.fitWidth : BoxFit.contain, + child: SizedBox( + width: (player.player?.aspectRatio ?? 16 / 9) * 720, + height: 720, + child: player.player?.build(context) ?? Container(), + ), ), ), ], @@ -137,7 +142,7 @@ class VideoPlayerScreen extends StatelessWidget { } } -class _PlayPauseOverlay extends StatelessWidget { +class _PlayPauseOverlay extends StatefulWidget { const _PlayPauseOverlay({ Key? key, required this.player, @@ -145,15 +150,58 @@ class _PlayPauseOverlay extends StatelessWidget { final PlayerModel player; + @override + State<_PlayPauseOverlay> createState() => _PlayPauseOverlayState(); +} + +class _PlayPauseOverlayState extends State<_PlayPauseOverlay> { + Duration _position = Duration.zero; + Timer? _timer; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(milliseconds: 500), (timer) async { + final pos = await widget.player.getPosition(); + if (mounted && pos != _position) { + setState(() { + _position = pos; + }); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + void _onPlayButton() { - if (!player.isPlaying()) player.toggleControls(false); - player.userSetPlayerState(!player.isPlaying()); + if (!widget.player.isPlaying()) widget.player.toggleControls(false); + widget.player.userSetPlayerState(!widget.player.isPlaying()); + } + + // custom seekbar for yt player, since video_player one is binded into another controller + // and needs more info for general abstract impl + void _seekTo(Offset localPosition, double width) { + if (widget.player.getDuration() <= 0) return; + final double relative = (localPosition.dx - 15) / (width - 30); + final double posSeconds = + relative.clamp(0.0, 1.0) * widget.player.getDuration(); + final newPos = Duration(milliseconds: (posSeconds * 1000).toInt()); + setState(() { + _position = newPos; + }); + widget.player.seekTo(newPos); + widget.player.sendPlayerState(widget.player.isPlaying()); } - String _timeText(VideoPlayerValue? value) { - if (value == null) return ''; - final p = _stringDuration(value.position); - final d = _stringDuration(value.duration); + String _timeText() { + final p = _stringDuration(_position); + final d = _stringDuration( + Duration(milliseconds: (widget.player.getDuration() * 1000).toInt()), + ); return '$p / $d'; } @@ -168,6 +216,7 @@ class _PlayPauseOverlay extends StatelessWidget { @override Widget build(BuildContext context) { + final player = widget.player; return AnimatedOpacity( opacity: player.showControls ? 1 : 0, duration: const Duration(milliseconds: 200), @@ -197,7 +246,7 @@ class _PlayPauseOverlay extends StatelessWidget { alignment: Alignment.bottomLeft, child: Padding( padding: const EdgeInsets.only(bottom: 32, left: 15), - child: Text(_timeText(player.controller?.value)), + child: Text(_timeText()), ), ), if (player.showControls) @@ -207,21 +256,73 @@ class _PlayPauseOverlay extends StatelessWidget { }, 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: player.player is RawPlayer + ? VideoProgressIndicator( + (player.player as RawPlayer).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, + ) + : LayoutBuilder( + builder: (context, constraints) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onHorizontalDragUpdate: (details) { + _seekTo( + details.localPosition, + constraints.maxWidth, + ); + }, + onTapDown: (details) { + _seekTo( + details.localPosition, + constraints.maxWidth, + ); + }, + child: Padding( + padding: const EdgeInsets.only( + bottom: 15, + left: 15, + right: 15, + ), + child: LinearProgressIndicator( + value: player.getDuration() > 0 + ? _position.inMilliseconds / + (player.getDuration() * 1000) + : 0, + backgroundColor: const Color.fromRGBO( + 200, + 200, + 200, + 0.2, + ), + valueColor: const AlwaysStoppedAnimation( + Color.fromRGBO(200, 0, 0, 0.75), + ), + ), + ), + ); + }, + ), ), ), if (player.showControls) diff --git a/pubspec.lock b/pubspec.lock index c252682..89da414 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: @@ -236,10 +236,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.10.0" leak_tracker: dependency: transitive description: @@ -372,10 +372,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: @@ -420,10 +420,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 +481,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: @@ -537,10 +537,10 @@ packages: dependency: transitive description: name: unicode - sha256: "0d99edbd2e74726bed2e4989713c8bec02e5581628e334d8c88c0271593fb402" + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 url: "https://pub.dev" source: hosted - version: "1.1.8" + version: "1.1.9" url_launcher: dependency: "direct main" description: @@ -561,10 +561,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 +593,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: @@ -625,10 +625,10 @@ packages: dependency: transitive description: name: video_player_android - sha256: ee4fd520b0cafa02e4a867a0f882092e727cdaa1a2d24762171e787f8a502b0a + sha256: e726b33894526cf96a3eefe61af054b0c3e7d254443b3695b3c142dc277291be url: "https://pub.dev" source: hosted - version: "2.9.1" + version: "2.9.3" video_player_avfoundation: dependency: transitive description: @@ -701,6 +701,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 + url: "https://pub.dev" + source: hosted + version: "4.13.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: eeeb3fcd5f0ff9f8446c9f4bbc18a99b809e40297528a3395597d03aafb9f510 + url: "https://pub.dev" + source: hosted + version: "4.10.11" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "0412b657a2828fb301e73509909e6ec02b77cd2b441ae9f77125d482b3ddf0e7" + url: "https://pub.dev" + source: hosted + version: "3.23.6" win32: dependency: transitive description: @@ -741,6 +773,23 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.5" + youtube_player_iframe: + dependency: "direct main" + description: + path: "packages/youtube_player_iframe" + ref: custom-fixes + resolved-ref: a131b4ffe0f163d5df0e62f0fd88e2b37c4748ff + url: "https://github.com/asfandyr380/youtube_player_flutter.git" + source: git + version: "5.2.3" + youtube_player_iframe_web: + dependency: transitive + description: + name: youtube_player_iframe_web + sha256: "333901d008634f2ea67ef27aba8d597567e4ff45f393290b948a739654ab6dca" + url: "https://pub.dev" + source: hosted + version: "3.1.2" sdks: dart: ">=3.10.0 <4.0.0" flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml index 61d1977..86af531 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,14 @@ dependencies: video_player: ^2.11.0 + # youtube_player_iframe: ^5.2.2 + + youtube_player_iframe: + git: + url: https://github.com/asfandyr380/youtube_player_flutter.git + ref: custom-fixes + path: packages/youtube_player_iframe + # perfect_volume_control: ^1.0.5 web_socket_channel: ^3.0.3