diff --git a/assets/translations/ar.json b/assets/translations/ar.json index a37719b0..56175953 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -408,6 +408,7 @@ "subtitle_preview_text": "نصٌّ تجريبيٌّ لمعاينة شكل الترجمة", "server": "الخادم", "change_server": "تغيير الخادم", + "cannot_seek_server": "لا يمكن التقديم على هذا الخادم. شاهد من البداية أو اختر خادمًا آخر.", "stream_not_found": "يُخفي هذا المصدر بثّه خلف مشغّل محمي. جرّب مصدراً آخر، أو افتحه في متصفّحك.", "subtitle_translation": "ترجمة النصوص", "auto_translate": "الترجمة التلقائية", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 5073368a..676b6d34 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -422,6 +422,7 @@ "subtitle_preview_text": "ڕێوییە قاوەییە خێراکە بەسەر سەگە تەمەڵەکەدا باز دەدات", "server": "سێرڤەر", "change_server": "گۆڕینی سێرڤەر", + "cannot_seek_server": "لەم سێرڤەرەدا ناتوانرێت بازبدرێت بۆ پێشەوە. لە سەرەتاوە سەیری بکە یان سێرڤەرێکی تر هەڵبژێرە.", "stream_not_found": "ئەم سەرچاوەیە پەخشەکەی لە پشت لێدەرێکی پارێزراو شاردۆتەوە. سەرچاوەیەکی تر تاقیبکەرەوە، یان لە وێبگەڕەکەتدا بیکەرەوە.", "subtitle_translation": "وەرگێڕانی ژێرنووس", "auto_translate": "وەرگێڕانی خۆکار", diff --git a/assets/translations/de.json b/assets/translations/de.json index 78d358cc..70ad67ef 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -408,6 +408,7 @@ "subtitle_preview_text": "Franz jagt im komplett verwahrlosten Taxi quer durch Bayern", "server": "Server", "change_server": "Server wechseln", + "cannot_seek_server": "Auf diesem Server kann man nicht vorspulen. Schau von Anfang an oder wähle einen anderen Server.", "stream_not_found": "Diese Quelle versteckt ihren Stream hinter einem geschützten Player. Probiere eine andere Quelle oder öffne sie im Browser.", "subtitle_translation": "Untertitelübersetzung", "auto_translate": "Automatisch übersetzen", diff --git a/assets/translations/en.json b/assets/translations/en.json index c91759f6..bf998d37 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -422,6 +422,7 @@ "subtitle_preview_text": "The quick brown fox jumps over the lazy dog", "server": "Server", "change_server": "Change server", + "cannot_seek_server": "This server can't skip ahead. Play from the start, or pick another server.", "stream_not_found": "This source hides its stream behind a protected player. Try another source, or open it in your browser.", "subtitle_translation": "Subtitle translation", "auto_translate": "Auto-translate", diff --git a/assets/translations/es.json b/assets/translations/es.json index 16ffea50..66ab3752 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -408,6 +408,7 @@ "subtitle_preview_text": "El veloz murciélago hindú comía feliz cardillo y kiwi", "server": "Servidor", "change_server": "Cambiar servidor", + "cannot_seek_server": "Este servidor no permite adelantar. Míralo desde el principio o elige otro servidor.", "stream_not_found": "Esta fuente esconde su flujo detrás de un reproductor protegido. Prueba otra fuente o ábrela en el navegador.", "subtitle_translation": "Traducción de subtítulos", "auto_translate": "Traducir automáticamente", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 5aa0e570..172f5b75 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -408,6 +408,7 @@ "subtitle_preview_text": "Portez ce vieux whisky au juge blond qui fume", "server": "Serveur", "change_server": "Changer de serveur", + "cannot_seek_server": "Ce serveur ne permet pas d'avancer. Regardez depuis le début ou choisissez un autre serveur.", "stream_not_found": "Cette source cache son flux derrière un lecteur protégé. Essaie une autre source, ou ouvre-la dans ton navigateur.", "subtitle_translation": "Traduction des sous-titres", "auto_translate": "Traduction automatique", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index ccf57325..2d84c664 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -422,6 +422,7 @@ "subtitle_preview_text": "The quick brown fox jumps over the lazy dog", "server": "सर्वर", "change_server": "सर्वर बदलें", + "cannot_seek_server": "इस सर्वर पर आगे नहीं बढ़ा सकते। शुरू से देखें या कोई दूसरा सर्वर चुनें।", "stream_not_found": "यह सोर्स अपनी स्ट्रीम को एक सुरक्षित प्लेयर के पीछे छिपाता है। कोई दूसरा सोर्स आज़माएँ या इसे अपने ब्राउज़र में खोलें।", "subtitle_translation": "सबटाइटल अनुवाद", "auto_translate": "ऑटो-अनुवाद", diff --git a/assets/translations/id.json b/assets/translations/id.json index f1993c42..67e90c3f 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -408,6 +408,7 @@ "subtitle_preview_text": "Muharjo seorang xenofobia universal yang takut pada kelinci", "server": "Server", "change_server": "Ganti server", + "cannot_seek_server": "Server ini tidak bisa dipercepat. Tonton dari awal atau pilih server lain.", "stream_not_found": "Sumber ini menyembunyikan alirannya di balik pemutar terlindungi. Coba sumber lain, atau buka di peramban.", "subtitle_translation": "Terjemahan subtitle", "auto_translate": "Terjemahkan otomatis", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index 5e4e47f1..31a3065f 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -422,6 +422,7 @@ "subtitle_preview_text": "Rovîyê qehweyî yê bilez di ser kûçikê tembel re diqevze", "server": "Server", "change_server": "Serverê biguherîne", + "cannot_seek_server": "Li ser vê serverê nikare were pêşve birin. Ji destpêkê temaşe bike an serverek din hilbijêre.", "stream_not_found": "Ev çavkanî weşana xwe li pişt lêdarekî parastî vedişêre. Çavkaniyeke din biceribîne, an wê di gerokê de veke.", "subtitle_translation": "Wergera binnivîsê", "auto_translate": "Wergera otomatîk", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index bed20698..67eac8f2 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -408,6 +408,7 @@ "subtitle_preview_text": "Pa's wijze lynx bezag vroom het fikse aquaduct", "server": "Server", "change_server": "Server wisselen", + "cannot_seek_server": "Op deze server kun je niet vooruitspoelen. Kijk vanaf het begin of kies een andere server.", "stream_not_found": "Deze bron verstopt zijn stream achter een beveiligde speler. Probeer een andere bron of open hem in je browser.", "subtitle_translation": "Ondertitelvertaling", "auto_translate": "Automatisch vertalen", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 593ce761..725d644c 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -408,6 +408,7 @@ "subtitle_preview_text": "Um pequeno jabuti xereta viu dez cegonhas felizes", "server": "Servidor", "change_server": "Trocar de servidor", + "cannot_seek_server": "Este servidor não permite avançar. Assista desde o início ou escolha outro servidor.", "stream_not_found": "Esta fonte esconde o stream atrás de um player protegido. Tente outra fonte ou abra no navegador.", "subtitle_translation": "Tradução de legendas", "auto_translate": "Traduzir automaticamente", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 2e068a8a..c6ae867f 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -408,6 +408,7 @@ "subtitle_preview_text": "Съешь ещё этих мягких французских булок", "server": "Сервер", "change_server": "Сменить сервер", + "cannot_seek_server": "На этом сервере нельзя перематывать. Смотрите с начала или выберите другой сервер.", "stream_not_found": "Этот источник прячет поток за защищённым плеером. Выберите другой источник или откройте в браузере.", "subtitle_translation": "Перевод субтитров", "auto_translate": "Автоперевод", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index 86029116..b6bc23c4 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -408,6 +408,7 @@ "subtitle_preview_text": "Pijamalı hasta yağız şoföre çabucak güvendi", "server": "Sunucu", "change_server": "Sunucuyu değiştir", + "cannot_seek_server": "Bu sunucuda ileri sarılamıyor. Baştan izleyin veya başka bir sunucu seçin.", "stream_not_found": "Bu kaynak akışını korumalı bir oynatıcının arkasına saklıyor. Başka bir kaynak dene ya da tarayıcında aç.", "subtitle_translation": "Altyazı çevirisi", "auto_translate": "Otomatik çevir", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 7eeb8ab6..ec21a8ce 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -408,6 +408,7 @@ "subtitle_preview_text": "Tez qo’ng’ir tulki yalqov itning ustidan sakraydi", "server": "Server", "change_server": "Serverni almashtirish", + "cannot_seek_server": "Bu serverda oldinga o'tkazib bo'lmaydi. Boshidan ko'ring yoki boshqa server tanlang.", "stream_not_found": "Bu manba oqimini himoyalangan pleyer ortida yashiradi. Boshqa manbani tanlang yoki brauzerda oching.", "subtitle_translation": "Subtitr tarjimasi", "auto_translate": "Avtomatik tarjima", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index c9acbd4f..ce8c89da 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -422,6 +422,7 @@ "subtitle_preview_text": "Con cáo nâu nhanh nhẹn nhảy qua chú chó lười", "server": "Máy chủ", "change_server": "Đổi máy chủ", + "cannot_seek_server": "Máy chủ này không tua được. Hãy xem từ đầu hoặc chọn máy chủ khác.", "stream_not_found": "Nguồn này giấu luồng phát sau một trình phát được bảo vệ. Hãy thử nguồn khác hoặc mở trong trình duyệt.", "subtitle_translation": "Dịch phụ đề", "auto_translate": "Tự động dịch", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 633e60af..66525659 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -422,6 +422,7 @@ "subtitle_preview_text": "敏捷嘅棕色狐狸跳過隻懶狗", "server": "伺服器", "change_server": "更換伺服器", + "cannot_seek_server": "呢個伺服器唔可以快轉。請由頭睇,或者揀另一個伺服器。", "stream_not_found": "呢個來源將串流收埋喺受保護嘅播放器後面。試下另一個來源,或者喺瀏覽器度開。", "subtitle_translation": "字幕翻譯", "auto_translate": "自動翻譯", diff --git a/lib/core/player/media_controller.dart b/lib/core/player/media_controller.dart index 9b5ae7c1..f9a50df7 100644 --- a/lib/core/player/media_controller.dart +++ b/lib/core/player/media_controller.dart @@ -223,6 +223,10 @@ abstract class PlayerController extends ValueNotifier { /// subtitle style, the same way it draws a downloaded subtitle file. ValueListenable get embeddedSubtitleText => _noEmbeddedText; + /// Turns true when the server refused a jump: the stream plays, but only + /// from where it is. mpv only; it never fires on the platform player. + ValueListenable get seekRefused => _neverRefused; + /// Whether this backend can adjust the picture while playing. /// /// libmpv only. The platform player exposes no runtime video equalizer at @@ -438,6 +442,7 @@ class PlayerAudioTrack { } final ValueNotifier _noEmbeddedText = ValueNotifier(''); +final ValueNotifier _neverRefused = ValueNotifier(false); /// One subtitle track inside the stream. class PlayerSubtitleTrack extends PlayerAudioTrack { @@ -696,6 +701,7 @@ class _MediaKitController extends PlayerController { List _subtitleTracks = const []; String? _activeSubtitleTrackId; final ValueNotifier _embeddedText = ValueNotifier(''); + final ValueNotifier _seekRefused = ValueNotifier(false); String? _activeVideoTrackId; final List> _subs = >[]; @@ -1127,6 +1133,13 @@ class _MediaKitController extends PlayerController { ) ..add( _player.stream.error.listen((e) { + // Reported as an error, it reopened the url at 0:00 — "skip 30 s + // starts it from the start" on UHD Movies' Google links, which + // ignore byte ranges. The film itself plays on. + if (isSeekRefusal(e)) { + _seekRefused.value = true; + return; + } // A live stream has no length and no cache to ride out a stall // on: once it plays, a dropped connection is reported at once so // the page reconnects instead of showing a frozen picture. @@ -1494,6 +1507,10 @@ class _MediaKitController extends PlayerController { ValueListenable get embeddedSubtitleText => _fallback != null ? _noEmbeddedText : _embeddedText; + @override + ValueListenable get seekRefused => + _fallback != null ? _neverRefused : _seekRefused; + @override Future setSubtitleTrack(String id) async { if (_fallback != null) return; @@ -1556,6 +1573,7 @@ class _MediaKitController extends PlayerController { Future _disposeOnce() async { if (!_closed.isCompleted) _closed.complete(); + _seekRefused.dispose(); await _teardownMpv(); await _backendSwitches.close(); await _fallback?.dispose(); diff --git a/lib/core/player/playback_readiness.dart b/lib/core/player/playback_readiness.dart index cd0b474d..62c31423 100644 --- a/lib/core/player/playback_readiness.dart +++ b/lib/core/player/playback_readiness.dart @@ -242,3 +242,12 @@ bool isDecodeGlitch(String error) { final l = error.toLowerCase(); return l.startsWith('error decoding') || l.contains('error while decoding'); } + +/// mpv's notice that the server ignores byte ranges: "Cannot seek in this +/// stream." and its "--force-seekable=yes" hint. The file keeps playing; only a +/// jump is refused, so it is not a playback error. +bool isSeekRefusal(String error) { + final l = error.toLowerCase(); + return l.contains('cannot seek in this stream') || + l.contains('force-seekable'); +} diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 59aa3535..12626be9 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -1066,6 +1066,7 @@ class _PlayerPageState extends State final c = _controller; if (c != null) { c.removeListener(_onMajorChange); + c.seekRefused.removeListener(_onSeekRefused); try { c.pause(); } catch (_) {} diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 71551e8b..c7f3ddf4 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -1689,6 +1689,7 @@ extension _PlayerMedia on _PlayerPageState { // Preview decoding starts only when the viewer scrubs. Eager warming // opens a second video decoder while a high-resolution stream starts. controller.addListener(_onMajorChange); + controller.seekRefused.addListener(_onSeekRefused); await controller.setLooping(false); if (!mounted || generation != _mediaGeneration) return; if (party != null) { @@ -2687,6 +2688,7 @@ extension _PlayerMedia on _PlayerPageState { unawaited(FramePreviewService.close()); if (c != null) { c.removeListener(_onMajorChange); + c.seekRefused.removeListener(_onSeekRefused); try { await c.pause(); } catch (_) {} diff --git a/lib/features/detail/presentation/pages/player_page.toast.dart b/lib/features/detail/presentation/pages/player_page.toast.dart index 9527d9ca..e638f787 100644 --- a/lib/features/detail/presentation/pages/player_page.toast.dart +++ b/lib/features/detail/presentation/pages/player_page.toast.dart @@ -24,6 +24,22 @@ extension _PlayerToasts on _PlayerPageState { }); } + /// The server ignores byte ranges: the film plays, but a skip cannot land. + /// Said once per stream, with the way out when this title has other servers. + void _onSeekRefused() { + final c = _controller; + if (c == null || !c.seekRefused.value) return; + _plog('stream is not seekable on this server', level: LogLevel.warn); + final servers = _affordances.hasServers; + _toast( + 'player.cannot_seek_server'.tr(), + icon: Icons.fast_forward_rounded, + duration: const Duration(seconds: 6), + action: servers ? 'player.change_server'.tr() : null, + onAction: servers ? _openServerSheet : null, + ); + } + void _dismissToast() { _toastTimer?.cancel(); _toastNote.value = null; diff --git a/test/core/player/playback_readiness_test.dart b/test/core/player/playback_readiness_test.dart index a8cd2c89..da526951 100644 --- a/test/core/player/playback_readiness_test.dart +++ b/test/core/player/playback_readiness_test.dart @@ -388,4 +388,15 @@ void main() { expect(isDecodeGlitch('Could not open codec.'), isFalse); expect(isDecodeGlitch('tcp: ffurl_read returned 0xffffff92'), isFalse); }); + + test('a refused seek is a notice, not a playback failure', () { + // UHD Movies' Google links ignore byte ranges (support ticket, 2026-11-09). + expect(isSeekRefusal('Cannot seek in this stream.'), isTrue); + expect( + isSeekRefusal("You can force it with '--force-seekable=yes'."), + isTrue, + ); + expect(isSeekRefusal('tcp: ffurl_read returned 0xffffff92'), isFalse); + expect(isSeekRefusal('Failed to open https://example.com/a.mkv.'), isFalse); + }); }