From d7dd5debbdf04ff06936e17ae3343d6b84310ebe Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:55:58 +0500 Subject: [PATCH] Player: media-segment relay and HLS header handling; safer download probe Relay image-wrapped TS CDN segments through the native MediaSegmentRelay so Referer-gated playback works; carry extracted media headers; probe mixed mirror candidates before enqueueing a download (reject 403s and HTML-success errors). Adds matching tests. --- .../soplay/sozo/DownloadForegroundService.kt | 16 +- .../com/soplay/sozo/HlsDownloadPlaylist.kt | 27 +- .../kotlin/com/soplay/sozo/MainActivity.kt | 2 + .../com/soplay/sozo/MediaSegmentRelay.kt | 19 + .../soplay/sozo/HlsDownloadPlaylistTest.kt | 7 + .../com/soplay/sozo/MediaSegmentRelayTest.kt | 14 + .../android_av1_decoder_test.dart | 85 +++ .../android_media_hotfix_test.dart | 191 ++++++ .../live_provider_playback_test.dart | 226 +++++++ lib/core/di/injection.dart | 29 + lib/core/player/download_media_probe.dart | 62 ++ lib/core/player/extracted_media_headers.dart | 34 + lib/core/player/hls_variants.dart | 55 +- lib/core/player/local_hls_proxy.dart | 16 +- lib/core/player/media_controller.dart | 46 +- lib/core/player/media_segment_relay.dart | 29 + lib/core/player/playback_readiness.dart | 20 + lib/core/player/quality_preference.dart | 6 +- lib/core/player/source_ladder.dart | 4 +- lib/core/player/webview_stream_extractor.dart | 82 +-- .../detail/domain/download_choices.dart | 8 +- .../domain/playback/playback_fault.dart | 2 + .../detail/domain/playback/retry_policy.dart | 1 + .../presentation/pages/detail_page.dart | 55 +- .../presentation/pages/player_page.dart | 1 + .../pages/player_page.history.dart | 1 + .../presentation/pages/player_page.media.dart | 193 +++--- .../pages/player_page.panels.dart | 607 +++++++++--------- .../download_native_data_source.dart | 4 + .../download_transfer_data_source.dart | 46 +- .../download_repository_impl.dart | 2 + .../domain/entities/download_request.dart | 5 +- .../domain/entities/download_selection.dart | 2 + .../usecases/download_request_builder.dart | 105 ++- .../widgets/download_choice_sheet.dart | 4 +- macos/Podfile.lock | 12 + .../player/download_media_probe_test.dart | 64 ++ .../player/extracted_media_headers_test.dart | 52 ++ test/core/player/hls_variants_test.dart | 35 +- test/core/player/local_hls_proxy_test.dart | 51 ++ .../core/player/media_segment_relay_test.dart | 36 ++ test/core/player/playback_readiness_test.dart | 62 ++ test/core/player/quality_preference_test.dart | 4 +- test/features/detail/retry_policy_test.dart | 1 + .../download_request_builder_test.dart | 104 +++ .../download/transfer_engine_test.dart | 12 +- test/features/mobile_ux_test.dart | 3 +- tool/generate_media_hotfix_fixtures.py | 52 ++ 48 files changed, 1937 insertions(+), 557 deletions(-) create mode 100644 android/app/src/main/kotlin/com/soplay/sozo/MediaSegmentRelay.kt create mode 100644 android/app/src/test/kotlin/com/soplay/sozo/MediaSegmentRelayTest.kt create mode 100644 integration_test/android_av1_decoder_test.dart create mode 100644 integration_test/android_media_hotfix_test.dart create mode 100644 integration_test/live_provider_playback_test.dart create mode 100644 lib/core/player/download_media_probe.dart create mode 100644 lib/core/player/extracted_media_headers.dart create mode 100644 lib/core/player/media_segment_relay.dart create mode 100644 test/core/player/download_media_probe_test.dart create mode 100644 test/core/player/extracted_media_headers_test.dart create mode 100644 test/core/player/media_segment_relay_test.dart create mode 100644 tool/generate_media_hotfix_fixtures.py diff --git a/android/app/src/main/kotlin/com/soplay/sozo/DownloadForegroundService.kt b/android/app/src/main/kotlin/com/soplay/sozo/DownloadForegroundService.kt index 534c5573..a8775a33 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/DownloadForegroundService.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/DownloadForegroundService.kt @@ -127,6 +127,8 @@ class DownloadForegroundService : Service() { (0 until entries.length()).map { parseHeaders(entries.optJSONObject(it)?.toString() ?: "{}") } }.getOrDefault(emptyList()) val wifiOnly = intent.getBooleanExtra(EXTRA_WIFI_ONLY, false) + val videoHeight = intent.getIntExtra(EXTRA_VIDEO_HEIGHT, 0) + val segmentRelay = intent.getStringExtra(EXTRA_SEGMENT_RELAY) if (id.isEmpty() || artefactPath.isEmpty()) return if (kind == KIND_MANGA) { @@ -189,7 +191,7 @@ class DownloadForegroundService : Service() { updateState(id, title, url, artefactPath, STATUS_DOWNLOADING, 0, 0, 0, null) val result = when (kind) { KIND_MANGA -> downloadPages(id, title, artefactPath, pageUrls, headers, imageHeaders, token) - KIND_HLS -> downloadHls(id, title, url, artefactPath, headers, token) + KIND_HLS -> downloadHls(id, title, url, artefactPath, headers, token, videoHeight, segmentRelay) else -> downloadFile(id, title, url, artefactPath, headers, token) } when { @@ -371,7 +373,9 @@ class DownloadForegroundService : Service() { url: String, artefactPath: String, headers: Map, - token: AtomicBoolean + token: AtomicBoolean, + videoHeight: Int, + segmentRelay: String? ): Transferred { val target = File(artefactPath) val folder = target.parentFile ?: throw IOException("no folder for the playlist") @@ -382,7 +386,7 @@ class DownloadForegroundService : Service() { var master: String? = null var audio: HlsDownloadPlaylist.AudioRendition? = null if (playlist.contains("#EXT-X-STREAM-INF")) { - playlistUrl = HlsDownloadPlaylist.pickVariantUrl(playlist, HlsDownloadPlaylist.baseUrlOf(url)) + playlistUrl = HlsDownloadPlaylist.pickVariantUrl(playlist, HlsDownloadPlaylist.baseUrlOf(url), videoHeight) ?: throw IOException("no variant in the master playlist") master = playlist audio = HlsDownloadPlaylist.audioRendition(playlist, HlsDownloadPlaylist.baseUrlOf(url), playlistUrl) @@ -459,7 +463,9 @@ class DownloadForegroundService : Service() { // process was never fetched again and the episode played to that // point and stopped. if (!segment.exists() || segment.length() == 0L) { - fetchPart(part.segments[i].url, segment, headers, token, part.segments[i].range) + val original = part.segments[i].url + val relayed = MediaSegmentRelay.rewrite(original, segmentRelay) + fetchPart(relayed ?: original, segment, if (relayed == null) headers else emptyMap(), token, part.segments[i].range) } if (token.get()) return@inParallel val size = bytes.addAndGet(segment.length()) @@ -1129,11 +1135,13 @@ class DownloadForegroundService : Service() { const val EXTRA_TITLE = "title" const val EXTRA_URL = "url" const val EXTRA_LOCAL_PATH = "local_path" + const val EXTRA_SEGMENT_RELAY = "segment_relay" const val EXTRA_HEADERS_JSON = "headers_json" const val EXTRA_KIND = "kind" const val EXTRA_PAGE_URLS_JSON = "page_urls_json" const val EXTRA_IMAGE_HEADERS_JSON = "image_headers_json" const val EXTRA_WIFI_ONLY = "wifi_only" + const val EXTRA_VIDEO_HEIGHT = "video_height" // Kinds, matching DownloadKind on the Dart side byte for byte. const val KIND_VIDEO = "video" diff --git a/android/app/src/main/kotlin/com/soplay/sozo/HlsDownloadPlaylist.kt b/android/app/src/main/kotlin/com/soplay/sozo/HlsDownloadPlaylist.kt index 4e626e03..98469b0e 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/HlsDownloadPlaylist.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/HlsDownloadPlaylist.kt @@ -20,8 +20,19 @@ object HlsDownloadPlaylist { * the packager's own `s1080p` name, else RESOLUTION's height, bitrate to * break a tie. */ - fun pickVariantUrl(playlist: String, baseUrl: String): String? { + fun pickVariantUrl(playlist: String, baseUrl: String, preferredHeight: Int = 0): String? { val lines = playlist.lines() + val heights = lines.mapNotNull { tag -> + val match = Regex("RESOLUTION=(\\d+)x(\\d+)").find(tag) ?: return@mapNotNull null + val w = match.groupValues[1].toInt() + val h = match.groupValues[2].toInt() + displayHeight(w, h) + } + val wanted = when { + preferredHeight < 0 -> heights.minOrNull() + preferredHeight > 0 -> heights.filter { it <= preferredHeight }.maxOrNull() ?: heights.minOrNull() + else -> null + } var best: String? = null var bestHeight = 0 var bestBandwidth = 0L @@ -30,10 +41,10 @@ object HlsDownloadPlaylist { if (!tag.startsWith("#EXT-X-STREAM-INF")) continue val uri = lines[i + 1].trim() if (uri.isEmpty() || uri.startsWith("#")) continue - val height = NAMED_HEIGHT.find(uri)?.groupValues?.get(1)?.toIntOrNull() - ?: RESOLUTION.find(tag)?.groupValues?.get(1)?.toIntOrNull() - ?: 0 - if (height <= 0) continue + val dimensions = Regex("RESOLUTION=(\\d+)x(\\d+)").find(tag) + val height = dimensions?.let { displayHeight(it.groupValues[1].toInt(), it.groupValues[2].toInt()) } + ?: NAMED_HEIGHT.find(uri)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + if (height <= 0 || (wanted != null && height != wanted)) continue val bandwidth = BANDWIDTH.find(tag)?.groupValues?.get(1)?.toLongOrNull() ?: 0L if (height > bestHeight || (height == bestHeight && bandwidth > bestBandwidth)) { best = resolveUrl(uri, baseUrl) @@ -72,6 +83,12 @@ object HlsDownloadPlaylist { return null } + private fun displayHeight(width: Int, height: Int): Int { + val effective = maxOf(minOf(width, height).toDouble(), maxOf(width, height) * 9.0 / 16) + return listOf(2160, 1440, 1080, 720, 480, 360, 240) + .firstOrNull { kotlin.math.abs(effective - it) <= it * 0.08 } ?: effective.toInt() + } + data class ByteRange(val offset: Long, val length: Long) data class Segment(val url: String, val range: ByteRange?) data class AuxEntry(val uri: String, val isMap: Boolean, val key: String, val range: ByteRange?) diff --git a/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt b/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt index 64625a4e..43f34fcb 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt @@ -308,6 +308,8 @@ class MainActivity : FlutterFragmentActivity() { .putExtra(DownloadForegroundService.EXTRA_URL, url) .putExtra(DownloadForegroundService.EXTRA_LOCAL_PATH, localPath) .putExtra(DownloadForegroundService.EXTRA_KIND, kind) + .putExtra(DownloadForegroundService.EXTRA_VIDEO_HEIGHT, call.argument("videoHeight") ?: 0) + .putExtra(DownloadForegroundService.EXTRA_SEGMENT_RELAY, call.argument("segmentRelay")) .putExtra( DownloadForegroundService.EXTRA_WIFI_ONLY, call.argument("wifiOnly") ?: false diff --git a/android/app/src/main/kotlin/com/soplay/sozo/MediaSegmentRelay.kt b/android/app/src/main/kotlin/com/soplay/sozo/MediaSegmentRelay.kt new file mode 100644 index 00000000..b6506475 --- /dev/null +++ b/android/app/src/main/kotlin/com/soplay/sozo/MediaSegmentRelay.kt @@ -0,0 +1,19 @@ +package com.soplay.sozo + +import java.net.URI +import java.net.URLEncoder + +internal object MediaSegmentRelay { + fun rewrite(raw: String, endpoint: String?): String? { + if (endpoint.isNullOrBlank()) return null + return try { + val u = URI(raw) + val relay = URI(endpoint) + if (relay.scheme != "https" || relay.userInfo != null || + u.scheme != "https" || u.userInfo != null || (u.port != -1 && u.port != 443) || + !Regex("^p(?:16|19)-ad-site-sign-sg\\.tiktokcdn\\.com$").matches(u.host ?: "") || + !u.path.startsWith("/ad-site-i18n-sg/") || !u.path.endsWith(".image")) null + else "$endpoint?url=${URLEncoder.encode(raw, "UTF-8")}" + } catch (_: Exception) { null } + } +} diff --git a/android/app/src/test/kotlin/com/soplay/sozo/HlsDownloadPlaylistTest.kt b/android/app/src/test/kotlin/com/soplay/sozo/HlsDownloadPlaylistTest.kt index 0705b07e..57270323 100644 --- a/android/app/src/test/kotlin/com/soplay/sozo/HlsDownloadPlaylistTest.kt +++ b/android/app/src/test/kotlin/com/soplay/sozo/HlsDownloadPlaylistTest.kt @@ -13,6 +13,13 @@ import org.junit.Test */ class HlsDownloadPlaylistTest { + @Test + fun `manual height keeps the selected rendition instead of highest bitrate`() { + val master = "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=12000000,RESOLUTION=1280x720\n720.m3u8\n" + + "#EXT-X-STREAM-INF:BANDWIDTH=18000000,RESOLUTION=1920x1080\n1080.m3u8\n" + assertEquals("https://cdn.test/720.m3u8", HlsDownloadPlaylist.pickVariantUrl(master, "https://cdn.test/", 720)) + } + private val base = "https://cdn.test/v/" @Test diff --git a/android/app/src/test/kotlin/com/soplay/sozo/MediaSegmentRelayTest.kt b/android/app/src/test/kotlin/com/soplay/sozo/MediaSegmentRelayTest.kt new file mode 100644 index 00000000..635563fd --- /dev/null +++ b/android/app/src/test/kotlin/com/soplay/sozo/MediaSegmentRelayTest.kt @@ -0,0 +1,14 @@ +package com.soplay.sozo +import org.junit.Assert.* +import org.junit.Test +import java.net.URLDecoder +class MediaSegmentRelayTest { + @Test fun onlyWrappedCdnUsesRelayAndPreservesSignature() { + val raw = "https://p16-ad-site-sign-sg.tiktokcdn.com/ad-site-i18n-sg/a.image?x-signature=a%2Bb" + val result = MediaSegmentRelay.rewrite(raw, "https://api.test/api/contents/media-segment")!! + assertEquals(raw, URLDecoder.decode(result.substringAfter("?url="), "UTF-8")) + assertNull(MediaSegmentRelay.rewrite("https://cdn.test/video.ts", "https://api.test/relay")) + assertNull(MediaSegmentRelay.rewrite(raw.replace(".com/", ".com.evil.test/"), "https://api.test/relay")) + assertNull(MediaSegmentRelay.rewrite(raw, null)) + } +} diff --git a/integration_test/android_av1_decoder_test.dart b/integration_test/android_av1_decoder_test.dart new file mode 100644 index 00000000..0993fd84 --- /dev/null +++ b/integration_test/android_av1_decoder_test.dart @@ -0,0 +1,85 @@ +// Exercises the actual libmpv -> platform recovery path with AV1 10-bit. +// Emulator routing is intentionally not initialized: this tests the controller +// selection used on physical Android phones, rather than the emulator override. +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/player/media_controller.dart'; +import 'package:soplay/core/player/player_engine.dart'; + +const fixture = String.fromEnvironment( + 'CODEC_FIXTURE', + defaultValue: 'av1-10bit.mp4', +); +const expectedHeight = int.fromEnvironment('CODEC_HEIGHT', defaultValue: 1080); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + late Directory dir; + late Box settings; + setUpAll(() async { + dir = await Directory( + '${(await getTemporaryDirectory()).path}/av1-decoder-check', + ).create(recursive: true); + Hive.init(dir.path); + settings = await Hive.openBox(AppConstants.settingsBox); + await settings.put(AppConstants.playerEngineKey, PlayerEngine.mediaKit.id); + final client = HttpClient(); + try { + final response = await (await client.getUrl( + Uri.parse('http://127.0.0.1:8766/$fixture'), + )).close(); + expect(response.statusCode, 200); + await response.pipe(File('${dir.path}/av1.mp4').openWrite()); + } finally { + client.close(); + } + }); + tearDownAll(() async { + await Hive.close(); + await dir.delete(recursive: true); + }); + for (final local in [false, true]) { + testWidgets( + '$fixture ${local ? "local" : "network"} mpv controller recovers or plays', + (tester) async { + final c = local + ? PlayerController.file(File('${dir.path}/av1.mp4')) + : PlayerController.networkUrl( + Uri.parse('http://127.0.0.1:8766/$fixture'), + ); + expect( + c.runtimeType.toString(), + '_MediaKitController', + reason: 'Exercise mpv, not emulator native shortcut', + ); + try { + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: c.buildView())), + ); + await c.initialize().timeout(const Duration(seconds: 50)); + expect(c.value.size.height, expectedHeight); + await c.play(); + final end = DateTime.now().add(const Duration(seconds: 15)); + while (c.value.position.inMilliseconds < 1000 && + DateTime.now().isBefore(end)) { + await Future.delayed(const Duration(milliseconds: 250)); + await tester.pump(); + } + expect(c.value.hasError, isFalse, reason: c.value.errorDescription); + expect(c.value.position.inMilliseconds, greaterThanOrEqualTo(1000)); + debugPrint( + 'CODEC_VERIFIED fixture=$fixture local=$local size=${c.value.size}', + ); + } finally { + await tester.pumpWidget(const SizedBox()); + await c.dispose(); + } + }, + ); + } +} diff --git a/integration_test/android_media_hotfix_test.dart b/integration_test/android_media_hotfix_test.dart new file mode 100644 index 00000000..e5e3c44c --- /dev/null +++ b/integration_test/android_media_hotfix_test.dart @@ -0,0 +1,191 @@ +// Device-level regression suite. Serve generated fixtures on localhost:8766 +// and use `adb reverse tcp:8766 tcp:8766` before running on Android. +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:soplay/core/player/local_hls_proxy.dart'; +import 'package:soplay/core/player/media_controller.dart'; +import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/features/download/data/datasources/download_native_data_source.dart'; +import 'package:soplay/features/download/domain/entities/download_failure.dart'; + +const base = String.fromEnvironment( + 'FIXTURE_BASE', + defaultValue: 'http://127.0.0.1:8766', +); +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + const native = DownloadNativeDataSource(); + late Directory scratch; + final ids = []; + setUpAll(() async { + await initTvPlatform(); + scratch = await Directory( + '${(await getTemporaryDirectory()).path}/hotfix-device-test', + ).create(recursive: true); + }); + tearDownAll(() async { + for (final id in ids) { + await native.forget(id); + } + await scratch.delete(recursive: true); + }); + + Future play( + WidgetTester tester, + PlayerController controller, + int height, + ) async { + try { + await tester.pumpWidget( + MaterialApp(home: Scaffold(body: controller.buildView())), + ); + await controller.initialize().timeout(const Duration(seconds: 35)); + expect(controller.value.isInitialized, isTrue); + expect(controller.value.hasError, isFalse); + expect( + controller.value.size.height, + height.toDouble(), + reason: 'Actual decoded video must match chosen resolution', + ); + await controller.play(); + final deadline = DateTime.now().add(const Duration(seconds: 12)); + while (controller.value.position.inMilliseconds < 800 && + DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 250)); + await tester.pump(); + } + expect( + controller.value.position.inMilliseconds, + greaterThanOrEqualTo(800), + reason: 'Video must actually advance, not just initialize', + ); + expect(controller.value.hasError, isFalse); + await controller.pause(); + } finally { + await tester.pumpWidget(const SizedBox()); + await controller.dispose(); + } + } + + Future download( + String name, + String url, + String kind, { + int? height, + }) async { + final id = 'hotfix-$name-${DateTime.now().microsecondsSinceEpoch}'; + ids.add(id); + final path = + '${scratch.path}/$name/${kind == 'hls' ? 'index.m3u8' : 'video.mp4'}'; + expect( + await native.start( + id: id, + title: 'Hotfix verification', + url: url, + artefactPath: path, + headers: const {}, + kind: kind, + pageUrls: const [], + wifiOnly: false, + videoHeight: height, + ), + isTrue, + ); + final deadline = DateTime.now().add(const Duration(seconds: 45)); + while (DateTime.now().isBefore(deadline)) { + for (final state in await native.states()) { + if (state.id == id && + ['completed', 'failed', 'cancelled'].contains(state.status)) { + return state; + } + } + await Future.delayed(const Duration(milliseconds: 250)); + } + fail('Native download did not finish: $name'); + } + + for (final height in [720, 1080]) { + testWidgets('HLS chosen $height decodes at $height', (tester) async { + final proxy = LocalHlsProxy(Dio()); + try { + final url = await proxy.register( + upstreamUrl: '$base/master.m3u8', + headers: const {}, + hlsHeight: height, + ); + await play( + tester, + PlayerController.networkUrl( + Uri.parse(url), + formatHint: VideoFormat.hls, + ), + height, + ); + } finally { + await proxy.dispose(); + } + }); + testWidgets('Android download $height retains quality and plays offline', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + final result = await download( + 'hls-$height', + '$base/master.m3u8', + 'hls', + height: height, + ); + expect(result.status, 'completed', reason: result.error); + expect(await File(result.artefactPath).exists(), isTrue); + final playlist = await File(result.artefactPath).readAsString(); + expect( + playlist, + isNot(contains('http://')), + reason: 'Offline playlist must reference only local segments', + ); + await play( + tester, + PlayerController.file(File(result.artefactPath)), + height, + ); + }); + } + testWidgets( + 'Wrapped CDN HLS downloads through relay and plays offline', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + final result = await download( + 'relay-720', + '$base/relay-720.m3u8', + 'hls', + height: 720, + ); + expect(result.status, 'completed', reason: result.error); + await play(tester, PlayerController.file(File(result.artefactPath)), 720); + }, + skip: !const bool.fromEnvironment('LIVE_PROVIDER_CHECK'), + ); + + testWidgets('Android MP4 download plays from disk', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + final result = await download('mp4', '$base/1080/video.mp4', 'video'); + expect(result.status, 'completed', reason: result.error); + await play(tester, PlayerController.file(File(result.artefactPath)), 1080); + }); + testWidgets('HTML response is rejected instead of saved as playable video', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + final result = await download('html', '$base/blocked.html', 'video'); + expect(result.status, 'failed'); + expect( + DownloadFailureKind.classify(result.error), + DownloadFailureKind.notMedia, + ); + expect(await File('${scratch.path}/html/video.mp4').exists(), isFalse); + }); +} diff --git a/integration_test/live_provider_playback_test.dart b/integration_test/live_provider_playback_test.dart new file mode 100644 index 00000000..2d33188a --- /dev/null +++ b/integration_test/live_provider_playback_test.dart @@ -0,0 +1,226 @@ +import 'package:dio/dio.dart'; +import 'package:soplay/core/player/local_hls_proxy.dart'; +import 'package:soplay/core/player/media_segment_relay.dart'; +// Opt-in live upstream verification: --dart-define=LIVE_PROVIDER_CHECK=true. +// Requires fresh anikage-sources.json served from localhost:8766 via adb reverse. +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:soplay/core/player/media_controller.dart'; +import 'package:soplay/core/network/user_agent.dart'; +import 'package:soplay/core/player/webview_stream_extractor.dart'; +import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/features/detail/domain/entities/extractor_config_entity.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + const live = bool.fromEnvironment('LIVE_PROVIDER_CHECK'); + setUpAll(() async { + await initTvPlatform(); + await initSozoUserAgent(); + }); + Future verify( + WidgetTester tester, + String url, + Map headers, + VideoFormat? format, + ) async { + final c = PlayerController.networkUrl( + Uri.parse(url), + httpHeaders: headers, + formatHint: format, + ); + try { + await tester.pumpWidget(MaterialApp(home: Scaffold(body: c.buildView()))); + await c.initialize().timeout(const Duration(seconds: 45)); + expect(c.value.size.height, greaterThan(0)); + await c.play(); + final end = DateTime.now().add(const Duration(seconds: 25)); + while (c.value.position.inMilliseconds < 1500 && + DateTime.now().isBefore(end)) { + await Future.delayed(const Duration(milliseconds: 250)); + await tester.pump(); + } + expect(c.value.hasError, isFalse, reason: c.value.errorDescription); + expect(c.value.position.inMilliseconds, greaterThanOrEqualTo(1500)); + debugPrint( + 'LIVE_VERIFIED ${c.value.size.width}x${c.value.size.height} position=${c.value.position.inMilliseconds}ms', + ); + } finally { + await tester.pumpWidget(const SizedBox()); + await c.dispose(); + } + } + + testWidgets('Nested player capture retains the inner frame origin', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + final stream = await WebViewStreamExtractor().extract( + pageUrl: 'http://127.0.0.1:8766/nested-player.html', + config: const ExtractorConfigEntity( + hostPattern: '127.0.0.1', + urlPatterns: ['.m3u8'], + captureHeaders: ['Referer', 'Origin', 'User-Agent'], + timeoutMs: 12000, + ), + pageHeaders: const {}, + ); + expect(stream, isNotNull); + expect(stream!.headers['Referer'], 'http://10.0.2.2:8766/'); + expect(stream.headers['Origin'], 'http://10.0.2.2:8766'); + }, skip: !live); + + testWidgets('Reported PrimeSrc LuluStream embed extracts and plays', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + final stream = await WebViewStreamExtractor().extract( + pageUrl: 'https://luluvdoo.com/e/u8f0iid8h1ze', + config: const ExtractorConfigEntity( + hostPattern: 'primesrc.me', + urlPatterns: ['.m3u8', '.mpd', '.mp4'], + captureHeaders: ['Referer', 'Origin', 'User-Agent', 'Cookie'], + timeoutMs: 25000, + ), + pageHeaders: const {'Referer': 'https://primesrc.me/'}, + ); + expect( + stream, + isNotNull, + reason: 'Embed must yield a media URL before reaching native player', + ); + await verify( + tester, + stream!.url, + stream.headers, + stream.playType == 'hls' ? VideoFormat.hls : null, + ); + }, skip: !live); + for (final provider in ['Megg', 'Wave']) { + testWidgets('Reported Anikage episode plays through $provider', ( + tester, + ) async { + final client = HttpClient(); + late List sources; + try { + final r = await (await client.getUrl( + Uri.parse('http://127.0.0.1:8766/anikage-sources.json'), + )).close(); + sources = + jsonDecode(await utf8.decoder.bind(r).join()) as List; + } finally { + client.close(); + } + final s = sources.cast>().firstWhere( + (s) => (s['label'] as String).startsWith('$provider · Sub'), + ); + await verify( + tester, + s['url'] as String, + Map.from(s['headers'] as Map), + s['container'] == 'hls' ? VideoFormat.hls : null, + ); + }, skip: !live); + } + testWidgets('AV1 10-bit 1080p plays on Android platform decoder', ( + tester, + ) async { + await verify(tester, 'http://127.0.0.1:8766/av1-10bit.mp4', const {}, null); + }, skip: !live); + testWidgets('PrimeSrc current alternate mirrors provide a playable source', ( + tester, + ) async { + final client = HttpClient(); + late Map data; + try { + final r = await (await client.getUrl( + Uri.parse('http://127.0.0.1:8766/primesrc-episodes.json'), + )).close(); + data = + jsonDecode(await utf8.decoder.bind(r).join()) as Map; + } finally { + client.close(); + } + var passed = 0; + for (final source + in (data['videoSources'] as List).cast>()) { + if (source['type'] != 'iframe' || + (source['host'] as String).contains('lulu')) { + continue; + } + if (const bool.fromEnvironment('STREAMWISH_ONLY') && + source['host'] != 'streamwish') { + continue; + } + final label = source['quality']; + try { + await tester.pumpWidget(const MaterialApp(home: Scaffold())); + final stream = await WebViewStreamExtractor().extract( + pageUrl: source['videoUrl'] as String, + config: const ExtractorConfigEntity( + hostPattern: 'primesrc.me', + urlPatterns: ['.m3u8', '.mpd', '.mp4'], + captureHeaders: ['Referer', 'Origin', 'User-Agent', 'Cookie'], + timeoutMs: 25000, + ), + pageHeaders: Map.from(source['headers'] as Map), + ); + if (stream == null) { + debugPrint('MIRROR_FAILED $label extraction timeout'); + continue; + } + final proxy = LocalHlsProxy(Dio()); + try { + final target = MediaSegmentRelay.needsManifestProxy(stream.url) + ? await proxy.register( + upstreamUrl: stream.url, + headers: stream.headers, + keepOriginHeaders: true, + ) + : stream.url; + await verify( + tester, + target, + stream.headers, + stream.playType == 'hls' ? VideoFormat.hls : null, + ); + } finally { + await proxy.dispose(); + } + passed++; + debugPrint('MIRROR_PASSED $label'); + } catch (e) { + debugPrint('MIRROR_FAILED $label $e'); + } + } + expect( + passed, + const bool.fromEnvironment('STREAMWISH_ONLY') + ? equals(2) + : greaterThan(0), + reason: 'Selected mirrors must actually play', + ); + }, skip: !live); + testWidgets('UHDMovies fresh episode 2 opens on Android', (tester) async { + final client = HttpClient(); + late Map data; + try { + final r = await (await client.getUrl( + Uri.parse('http://127.0.0.1:8766/uhdmovies-media.json'), + )).close(); + data = + jsonDecode(await utf8.decoder.bind(r).join()) as Map; + } finally { + client.close(); + } + await verify( + tester, + data['videoUrl'] as String, + Map.from(data['headers'] as Map? ?? {}), + null, + ); + }, skip: !live); +} diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index 8a4a9c4d..d1609170 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -1,3 +1,6 @@ +import 'package:soplay/core/player/download_media_probe.dart'; +import 'package:soplay/features/detail/domain/entities/extractor_config_entity.dart'; +import 'package:soplay/features/detail/domain/entities/media_resolve_entity.dart'; import 'dart:async'; import 'dart:io' show Platform; @@ -949,6 +952,32 @@ Future configureDependencies() async { DownloadRequestBuilder( resolve: getIt(), getPages: getIt(), + canDownload: DownloadMediaProbe(getIt().dio).accepts, + resolveEmbed: (media) async { + final stream = await getIt().extract( + pageUrl: media.videoUrl, + pageHeaders: media.headers, + config: + media.extractor ?? + ExtractorConfigEntity( + hostPattern: Uri.tryParse(media.videoUrl)?.host ?? '', + urlPatterns: const ['.m3u8', '.mpd', '.mp4'], + captureHeaders: const [ + 'Referer', + 'Origin', + 'User-Agent', + 'Cookie', + ], + ), + ); + if (stream == null) return null; + return MediaResolveEntity( + videoUrl: stream.url, + headers: stream.headers, + type: stream.playType, + subtitles: media.subtitles, + ); + }, ), ); getIt.registerSingleton(AutomationSettings()); diff --git a/lib/core/player/download_media_probe.dart b/lib/core/player/download_media_probe.dart new file mode 100644 index 00000000..b7247205 --- /dev/null +++ b/lib/core/player/download_media_probe.dart @@ -0,0 +1,62 @@ +import 'dart:convert'; +import 'package:dio/dio.dart'; +import 'package:soplay/features/detail/domain/entities/media_resolve_entity.dart'; + +/// Checks mixed mirror candidates before enqueueing one. Reads only a prefix, +/// never the movie, and rejects an HTTP-success HTML error as well as 403s. +class DownloadMediaProbe { + DownloadMediaProbe(this.dio); + final Dio dio; + + Future accepts(MediaResolveEntity media) async { + final cancel = CancelToken(); + try { + final response = await dio + .get( + media.videoUrl, + cancelToken: cancel, + options: Options( + headers: {...media.headers, 'Range': 'bytes=0-2047'}, + responseType: ResponseType.stream, + sendTimeout: const Duration(seconds: 8), + receiveTimeout: const Duration(seconds: 8), + validateStatus: (_) => true, + ), + ) + .timeout(const Duration(seconds: 8)); + if ((response.statusCode ?? 500) >= 400 || response.data == null) { + return false; + } + final bytes = []; + await for (final chunk in response.data!.stream.timeout( + const Duration(seconds: 8), + )) { + bytes.addAll(chunk.take(2048 - bytes.length)); + if (bytes.length >= 512) break; + } + final text = utf8.decode(bytes, allowMalformed: true).trimLeft(); + if (media.type == 'hls' || media.videoUrl.contains('.m3u8')) { + return text.startsWith('#EXTM3U'); + } + if (media.type == 'dash' || media.videoUrl.contains('.mpd')) { + return RegExp(r')').hasMatch(text); + } + if (text.toLowerCase().startsWith('= 12 && + (String.fromCharCodes(bytes.sublist(4, 8)) == 'ftyp' || + (bytes[0] == 0x1a && + bytes[1] == 0x45 && + bytes[2] == 0xdf && + bytes[3] == 0xa3) || + bytes[0] == 0x47 || + text.startsWith('#EXTM3U')); + } catch (_) { + return false; + } finally { + cancel.cancel('Media prefix checked'); + } + } +} diff --git a/lib/core/player/extracted_media_headers.dart b/lib/core/player/extracted_media_headers.dart new file mode 100644 index 00000000..232397ba --- /dev/null +++ b/lib/core/player/extracted_media_headers.dart @@ -0,0 +1,34 @@ +/// Headers for media discovered inside an embed page. +Map extractedMediaHeaders({ + required String pageUrl, + required String userAgent, + required Map pageHeaders, + required Map capturedHeaders, + required List captureHeaders, +}) { + final headers = {}; + capturedHeaders.forEach((key, value) { + if (value == null || key.isEmpty) return; + if (captureHeaders.any((h) => h.toLowerCase() == key.toLowerCase()) || + key.toLowerCase().startsWith('x-')) { + headers[key] = value.toString(); + } + }); + // Navigation into the embed uses the catalog's Referer. Its subsequent + // media request belongs to the embed, and uses the WebView's real agent. + // Actual intercepted request headers remain authoritative. + bool contains(String name) => + headers.keys.any((h) => h.toLowerCase() == name.toLowerCase()); + pageHeaders.forEach((key, value) { + if (const {'user-agent', 'referer', 'origin'}.contains(key.toLowerCase())) { + return; + } + if (!contains(key)) headers[key] = value; + }); + if (!contains('User-Agent')) headers['User-Agent'] = userAgent; + if (!contains('Referer')) { + headers['Referer'] = '${Uri.parse(pageUrl).origin}/'; + } + if (!contains('Origin')) headers['Origin'] = Uri.parse(pageUrl).origin; + return headers; +} diff --git a/lib/core/player/hls_variants.dart b/lib/core/player/hls_variants.dart index 2705d7d9..0af04826 100644 --- a/lib/core/player/hls_variants.dart +++ b/lib/core/player/hls_variants.dart @@ -37,11 +37,8 @@ List parseHlsVariants(String playlist, Uri base) { final uri = lines[i + 1].trim(); if (uri.isEmpty || uri.startsWith('#')) continue; - // The packager's own name for the variant beats RESOLUTION, because the two - // disagree more often than not: a 2.40:1 film encoded at 1080p carries - // RESOLUTION=1920x800, and naming it after the pixel height would offer - // `800p` for the stream every other player calls 1080p. The `s1080p` token - // in `index-s1080p-v1-a1` is what the packager called it. + // Trust manifest dimensions before a filename's marketing label. The + // aspect-ratio normalization still calls a 1920x800 film 1080p. final named = _namedHeight.firstMatch(uri); final res = _resolution.firstMatch(tag); // Without a name, the height a viewer would call it by both sides — @@ -49,8 +46,8 @@ List parseHlsVariants(String playlist, Uri base) { final width = int.tryParse(res?.group(1) ?? '') ?? 0; final pixels = int.tryParse(res?.group(2) ?? '') ?? 0; final height = - int.tryParse(named?.group(1) ?? '') ?? QualityPreference.displayHeight(width, pixels) ?? + int.tryParse(named?.group(1) ?? '') ?? pixels; if (height <= 0) continue; @@ -59,11 +56,7 @@ List parseHlsVariants(String playlist, Uri base) { // renditions by the wrong number. final bw = int.tryParse(_bandwidth.firstMatch(tag)?.group(1) ?? '') ?? 0; - out.add(( - height: height, - bandwidth: bw, - url: base.resolve(uri).toString(), - )); + out.add((height: height, bandwidth: bw, url: base.resolve(uri).toString())); } // Best first, and by bitrate within a height — so the 1080p a caller takes // when it just wants "the best" is the better of the two 1080p renditions @@ -103,3 +96,43 @@ String describeVariant(HlsVariant v) { : '${(v.bandwidth / 1000).round()} kbps'; return '${v.height}p · $rate'; } + +/// Select by measured resolution, with bitrate only breaking equal-height ties. +HlsVariant? selectHlsVariant(String playlist, Uri base, int? height) { + final variants = parseHlsVariants(playlist, base); + if (variants.isEmpty) return null; + final wanted = QualityPreference.pick( + variants.map((v) => v.height), + height ?? 0, + ); + return wanted == null + ? variants.first + : variants.firstWhere((v) => v.height == wanted); +} + +/// Keep a single video rendition while retaining audio, subtitles and keys. +String narrowHlsMaster(String playlist, Uri base, int height) { + final selected = selectHlsVariant(playlist, base, height); + if (selected == null) return playlist; + final lines = playlist.split(RegExp(r'\r?\n')); + final out = []; + for (var i = 0; i < lines.length; i++) { + if (lines[i].trim().startsWith('#EXT-X-STREAM-INF:')) { + final tag = lines[i]; + var j = i + 1; + while (j < lines.length && + (lines[j].trim().isEmpty || lines[j].trim().startsWith('#'))) { + j++; + } + if (j >= lines.length) continue; + if (base.resolve(lines[j].trim()).toString() == selected.url) { + out.add(tag); + out.add(lines[j]); + } + i = j; + } else { + out.add(lines[i]); + } + } + return out.join('\n'); +} diff --git a/lib/core/player/local_hls_proxy.dart b/lib/core/player/local_hls_proxy.dart index a0434864..3661ebc1 100644 --- a/lib/core/player/local_hls_proxy.dart +++ b/lib/core/player/local_hls_proxy.dart @@ -6,6 +6,8 @@ import 'dart:math'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; import 'package:soplay/core/player/dash_manifest.dart'; +import 'package:soplay/core/player/hls_variants.dart'; +import 'package:soplay/core/player/media_segment_relay.dart'; class LocalHlsProxy { LocalHlsProxy(this._dio); @@ -32,6 +34,7 @@ class LocalHlsProxy { // manifest — how a DASH quality is picked, since neither engine exposes // DASH track selection. String? dashRepresentation, + int? hlsHeight, // Send the Origin and Referer given here upstream. Off by default: the // sources routed through the proxy for playback are the ones whose CDN // refuses them. The seek-bar preview wants the opposite — the exact @@ -59,6 +62,7 @@ class LocalHlsProxy { ), lastAccess: DateTime.now(), dashRepresentation: dashRepresentation, + hlsHeight: hlsHeight, keepOriginHeaders: keepOriginHeaders, longLived: longLived, ); @@ -241,7 +245,13 @@ class LocalHlsProxy { String rewritten; try { rewritten = _rewriteM3u8( - text, + sess.hlsHeight == null + ? text + : narrowHlsMaster( + text, + Uri.parse(upstreamUrl), + sess.hlsHeight!, + ), base: '/hls/$sid', sessionOrigin: sess.origin, upstreamUrl: upstreamUrl, @@ -345,6 +355,8 @@ class LocalHlsProxy { } catch (_) { return '$base/$trimmed'; } + final relay = MediaSegmentRelay.rewrite(abs); + if (relay != null) return relay; final query = abs.hasQuery ? '?${abs.query}' : ''; if (abs.authority == sessionAuthority) { return '$base${abs.path}$query'; @@ -582,11 +594,13 @@ class _Session { required this.transform, required this.lastAccess, this.dashRepresentation, + this.hlsHeight, this.keepOriginHeaders = false, this.longLived = false, }); final String? dashRepresentation; + final int? hlsHeight; final bool keepOriginHeaders; final bool longLived; final String origin; diff --git a/lib/core/player/media_controller.dart b/lib/core/player/media_controller.dart index 10da7425..5924ac01 100644 --- a/lib/core/player/media_controller.dart +++ b/lib/core/player/media_controller.dart @@ -98,7 +98,9 @@ abstract class PlayerController extends ValueNotifier { // but if it somehow does, falling through to the native backend is the // safe answer, not a crash. if (!preferPlatform && _useMediaKit() && _mediaKitUsable()) { - return _MediaKitController(_MediaKitSource.uri(url, httpHeaders)); + return _MediaKitController( + _MediaKitSource.uri(url, httpHeaders, formatHint: formatHint), + ); } return _NativeController( vp.VideoPlayerController.networkUrl( @@ -554,11 +556,12 @@ class _NativeController extends PlayerController { } class _MediaKitSource { - _MediaKitSource.uri(Uri uri, this.headers) + _MediaKitSource.uri(Uri uri, this.headers, {this.formatHint}) : source = uri.isScheme('file') ? uri.toFilePath() : uri.toString(), uri = uri.isScheme('file') ? null : uri; _MediaKitSource.path(this.source) : headers = const {}, + formatHint = null, uri = null; /// What libmpv is given: a URL string, or a path for a local file. @@ -568,6 +571,7 @@ class _MediaKitSource { /// as one string and the platform backend does not — it has a separate /// constructor for each, and by then the distinction is gone. final Uri? uri; + final vp.VideoFormat? formatHint; final Map headers; } @@ -721,26 +725,21 @@ class _MediaKitController extends PlayerController { debugPrint('[player] hr-seek-framedrop not applied: $e'); } } - await _player.open( - mk.Media( - _src.source, - httpHeaders: _src.headers.isEmpty ? null : _src.headers, - ), - play: false, - ); - try { - await _awaitReady(); - } on TimeoutException { - if (_disposed) return; - if (!kIsWeb && Platform.isAndroid) { - debugPrint( - '[player] libmpv never produced video parameters; trying platform playback', + final recovered = await initializeWithDecoderFallback( + initialize: () async { + await _player.open( + mk.Media( + _src.source, + httpHeaders: _src.headers.isEmpty ? null : _src.headers, + ), + play: false, ); - await _swapToPlatformBackend(); - return; - } - rethrow; - } + await _awaitReady(); + }, + canFallback: () => !_disposed && !kIsWeb && Platform.isAndroid, + fallback: _swapToPlatformBackend, + ); + if (recovered) return; if (_disposed) return; final w = _player.state.width ?? 0; final h = _player.state.height ?? 0; @@ -780,12 +779,13 @@ class _MediaKitController extends PlayerController { await _teardownMpv(); if (_disposed) return; - final replacement = _src.uri == null + final replacement = _src.uri == null && _src.formatHint == null ? _NativeController(vp.VideoPlayerController.file(File(_src.source))) : _NativeController( vp.VideoPlayerController.networkUrl( - _src.uri!, + _src.uri ?? Uri.file(_src.source), httpHeaders: _src.headers, + formatHint: _src.formatHint, ), ); _fallback = replacement; diff --git a/lib/core/player/media_segment_relay.dart b/lib/core/player/media_segment_relay.dart new file mode 100644 index 00000000..24cee6fe --- /dev/null +++ b/lib/core/player/media_segment_relay.dart @@ -0,0 +1,29 @@ +import 'package:soplay/core/constants/app_constants.dart'; + +/// Only the image-wrapped TS CDN needs the server's network route. Signed +/// manifests must still be fetched by the device that extracted their token. +class MediaSegmentRelay { + static bool needsManifestProxy(String url) { + final uri = Uri.tryParse(url); + if (uri == null) return false; + // The embed rotates CDN domains. Its stream route is stable; restricting + // this to yesterday's two hostnames silently bypasses segment rewriting. + return uri.path.startsWith('/stream/') && uri.path.endsWith('.m3u8'); + } + + static String? rewrite(Uri url, {String? endpoint}) { + if (url.scheme != 'https' || + url.userInfo.isNotEmpty || + url.port != 443 || + !RegExp( + r'^p(?:16|19)-ad-site-sign-sg\.tiktokcdn\.com$', + ).hasMatch(url.host) || + !url.path.startsWith('/ad-site-i18n-sg/') || + !url.path.endsWith('.image')) { + return null; + } + return Uri.parse( + endpoint ?? '${AppConstants.baseUrl}/contents/media-segment', + ).replace(queryParameters: {'url': url.toString()}).toString(); + } +} diff --git a/lib/core/player/playback_readiness.dart b/lib/core/player/playback_readiness.dart index a04884d6..79e71a6a 100644 --- a/lib/core/player/playback_readiness.dart +++ b/lib/core/player/playback_readiness.dart @@ -1,5 +1,25 @@ import 'dart:async'; +/// A failed mpv decoder can still be supported by Android's platform player. +/// Network and format failures must go back to source recovery instead. +Future initializeWithDecoderFallback({ + required Future Function() initialize, + required Future Function() fallback, + required bool Function() canFallback, +}) async { + try { + await initialize(); + return false; + } catch (error) { + final decoder = error.toString().toLowerCase().contains( + 'could not open codec', + ); + if (!canFallback() || !(decoder || error is TimeoutException)) rethrow; + await fallback(); + return true; + } +} + /// Metadata (notably duration) can arrive before the video decoder has output. /// Only dimensions or an explicitly audio-only track list establish readiness. class PlaybackReadiness { diff --git a/lib/core/player/quality_preference.dart b/lib/core/player/quality_preference.dart index 8b10b73f..94e95386 100644 --- a/lib/core/player/quality_preference.dart +++ b/lib/core/player/quality_preference.dart @@ -34,9 +34,8 @@ abstract final class QualityPreference { /// Whether the source playing is a lone adaptive stream whose master is /// worth reading for renditions. /// - /// A label that already names a resolution came from the provider, which - /// knows its catalogue better than a parsed manifest; and rows hung off this - /// label mean the work is already done. + /// Provider labels can describe the maximum, even when the URL is adaptive. + /// Read that master; existing sibling rendition rows prevent duplicate work. static bool shouldExpand({ required String label, required String url, @@ -44,7 +43,6 @@ abstract final class QualityPreference { required Iterable siblingLabels, }) { if (url.isEmpty) return false; - if (VideoOptionGroups.resolutionOf(label) != null) return false; final kind = type?.toLowerCase(); final lower = url.toLowerCase(); final looksHls = kind == 'hls' || kind == 'm3u8' || lower.contains('.m3u8'); diff --git a/lib/core/player/source_ladder.dart b/lib/core/player/source_ladder.dart index bb507869..3639051e 100644 --- a/lib/core/player/source_ladder.dart +++ b/lib/core/player/source_ladder.dart @@ -69,7 +69,9 @@ class SourceLadder { static bool isPlayable(VideoSourceEntity s, {required bool hasDirective}) { if (!s.accessible) return false; if (s.videoUrl.isEmpty) return false; - if (s.type == 'iframe' && !hasDirective) return false; + if ((s.type == 'iframe' || s.type == 'webview-extract') && !hasDirective) { + return false; + } return true; } diff --git a/lib/core/player/webview_stream_extractor.dart b/lib/core/player/webview_stream_extractor.dart index fe5a7ea1..fd7baa52 100644 --- a/lib/core/player/webview_stream_extractor.dart +++ b/lib/core/player/webview_stream_extractor.dart @@ -1,4 +1,7 @@ import 'dart:async'; +import 'dart:collection'; + +import 'package:soplay/core/player/extracted_media_headers.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; @@ -141,7 +144,8 @@ class WebViewStreamExtractor { function __report(url, headers) { try { if (window.flutter_inappwebview && window.flutter_inappwebview.callHandler) { - window.flutter_inappwebview.callHandler('streamCaptured', url, headers || {}); + var h = Object.assign({Referer: location.origin + '/', Origin: location.origin}, headers || {}); + window.flutter_inappwebview.callHandler('streamCaptured', url, h); } } catch (_) {} } @@ -292,27 +296,13 @@ class WebViewStreamExtractor { : lower.contains('.mp4') ? 'progressive' : config.playType; - final headers = {}; - rawHeaders.forEach((k, v) { - if (v == null || k.isEmpty) return; - final keep = - config.captureHeaders.any( - (h) => h.toLowerCase() == k.toLowerCase(), - ) || - k.toLowerCase().startsWith('x-'); - if (keep) headers[k] = v.toString(); - }); - pageHeaders.forEach((k, v) { - if (!headers.keys.any((e) => e.toLowerCase() == k.toLowerCase())) { - headers[k] = v; - } - }); - headers.putIfAbsent('User-Agent', () => userAgent); - headers.putIfAbsent( - 'Referer', - () => 'https://${Uri.parse(pageUrl).host}/', + final headers = extractedMediaHeaders( + pageUrl: pageUrl, + userAgent: userAgent, + pageHeaders: pageHeaders, + capturedHeaders: rawHeaders, + captureHeaders: config.captureHeaders, ); - headers.putIfAbsent('Origin', () => 'https://${Uri.parse(pageUrl).host}'); if (kDebugMode) { debugPrint('[WebViewExtractor] captured $url headers=$headers'); @@ -348,6 +338,30 @@ class WebViewStreamExtractor { headless = HeadlessInAppWebView( webViewEnvironment: await WebViewEnv.ensure(), + // Register before navigation. Adding these asynchronously in + // onWebViewCreated races cached/fast pages and misses their first fetch. + initialUserScripts: UnmodifiableListView([ + UserScript( + source: _antiDebuggerShim, + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + ), + UserScript( + source: kWebViewMediaGuard, + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + ), + UserScript( + source: _xhrCaptureShim([config.hostPattern], config.urlPatterns), + forMainFrameOnly: false, + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + ), + UserScript( + // Android document-end evaluation runs only in the top frame. + // The delayed nudge must also run inside cross-origin players. + source: _gestureShim, + forMainFrameOnly: false, + injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, + ), + ]), initialUrlRequest: URLRequest( url: WebUri(pageUrl), headers: referer != null ? {'Referer': referer} : null, @@ -371,32 +385,6 @@ class WebViewStreamExtractor { ), onWebViewCreated: (controller) async { try { - await controller.addUserScript( - userScript: UserScript( - source: _antiDebuggerShim, - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - ), - ); - // Muted, no picture-in-picture, paused once it has asked for what - // was worth sniffing — see [kWebViewMediaGuard]. - await controller.addUserScript( - userScript: UserScript( - source: kWebViewMediaGuard, - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - ), - ); - await controller.addUserScript( - userScript: UserScript( - source: _xhrCaptureShim([config.hostPattern], config.urlPatterns), - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, - ), - ); - await controller.addUserScript( - userScript: UserScript( - source: _gestureShim, - injectionTime: UserScriptInjectionTime.AT_DOCUMENT_END, - ), - ); controller.addJavaScriptHandler( handlerName: 'streamCaptured', callback: (args) { diff --git a/lib/features/detail/domain/download_choices.dart b/lib/features/detail/domain/download_choices.dart index 6eeaf433..79472c5c 100644 --- a/lib/features/detail/domain/download_choices.dart +++ b/lib/features/detail/domain/download_choices.dart @@ -108,7 +108,7 @@ abstract final class DownloadChoices { // They say which they sent: `iframe` for the embed page, `hls` for the // manifest. final kind = type?.toLowerCase(); - if (kind == 'iframe') return false; + if (kind == 'iframe' || kind == 'webview-extract') return false; // Unknown shape plus an expected sniff is the one case worth refusing on // the directive alone — there is nothing else to go on. if (kind == null && hasDirective) return false; @@ -175,7 +175,11 @@ abstract final class DownloadChoices { s.videoUrl, isCurrent: false, headers: s.headers, - needsSniff: hasDirective, + needsSniff: + hasDirective && + (s.type == null || + s.type == 'iframe' || + s.type == 'webview-extract'), ); } return offers; diff --git a/lib/features/detail/domain/playback/playback_fault.dart b/lib/features/detail/domain/playback/playback_fault.dart index c88aaa52..bdd834a2 100644 --- a/lib/features/detail/domain/playback/playback_fault.dart +++ b/lib/features/detail/domain/playback/playback_fault.dart @@ -88,11 +88,13 @@ class PlaybackFault { return PlaybackFault(PlaybackFaultKind.unsupportedFormat, raw: raw); } if (lower.contains('mediacodec') || + lower.contains('could not open codec') || lower.contains('decoder') || lower.contains('renderer')) { return PlaybackFault(PlaybackFaultKind.decoder, raw: raw); } if (lower.contains('source error') || + lower.contains('failed to recognize file format') || lower.contains('unrecognizedinputformat') || lower.contains('nodeclaredbrand')) { return PlaybackFault(PlaybackFaultKind.sourceBlocked, raw: raw); diff --git a/lib/features/detail/domain/playback/retry_policy.dart b/lib/features/detail/domain/playback/retry_policy.dart index 93e0d174..d183ec23 100644 --- a/lib/features/detail/domain/playback/retry_policy.dart +++ b/lib/features/detail/domain/playback/retry_policy.dart @@ -75,6 +75,7 @@ abstract final class RetryPolicy { l.contains('exceeds selected codec') || l.contains('mediacodecvideodecoderexception') || l.contains('decoder failed') || + l.contains('could not open codec') || l.contains('decoderinitializationexception') || l.contains('no_unsupported_type'); } diff --git a/lib/features/detail/presentation/pages/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index 84370b09..5934a74e 100644 --- a/lib/features/detail/presentation/pages/detail_page.dart +++ b/lib/features/detail/presentation/pages/detail_page.dart @@ -1,3 +1,4 @@ +import 'package:soplay/features/download/domain/usecases/download_request_builder.dart'; import 'package:soplay/core/content/content_mode.dart'; import 'package:soplay/features/detail/presentation/widgets/detail_about_tab.dart'; import 'package:soplay/core/content/catalogue.dart'; @@ -694,7 +695,10 @@ class _DetailViewState extends State<_DetailView> ? playback.episodes.first.mediaRef : ''; final needsResolve = - playback.type == 'webview-extract' || url == null || url.isEmpty; + playback.type == 'webview-extract' || + playback.type == 'iframe' || + url == null || + url.isEmpty; if (needsResolve && ref.isNotEmpty) { final result = await getIt()( ref: ref, @@ -703,21 +707,19 @@ class _DetailViewState extends State<_DetailView> if (!mounted) return; if (result is Success && result.value.videoUrl.isNotEmpty) { - // Same hazard as the episode list: under a directive this url is the - // embed page, and nothing here will sniff it. Refuse with advice - // rather than downloading an HTML file that fails on first open. - if (!DownloadChoices.isDownloadableUrl( - url: result.value.videoUrl, - type: result.value.type, - hasDirective: result.value.extractor != null, - )) { + final prepared = await getIt().prepareVideo( + result.value, + ); + if (!mounted) return; + final media = prepared.media; + if (media == null) { _showSnack('detail.download_needs_playback'.tr()); return; } - url = result.value.videoUrl; - headers = result.value.headers; - sources = result.value.videoSources; - type = result.value.type; + url = media.videoUrl; + headers = media.headers; + sources = media.videoSources; + type = media.type; if (result.value.subtitles.isNotEmpty) { subtitles = result.value.subtitles; } @@ -729,6 +731,32 @@ class _DetailViewState extends State<_DetailView> return; } + if (!DownloadChoices.isDownloadableUrl( + url: url, + type: type, + hasDirective: false, + ) || + sources.any((s) => s.type == 'iframe' || s.type == 'webview-extract')) { + final prepared = await getIt().prepareVideo( + MediaResolveEntity( + videoUrl: url, + headers: headers, + type: type, + videoSources: sources, + ), + ); + if (!mounted) return; + final media = prepared.media; + if (media == null) { + _showSnack('detail.download_needs_playback'.tr()); + return; + } + url = media.videoUrl; + headers = media.headers; + sources = media.videoSources; + type = media.type; + } + final selection = await chooseDownload( context, url: url, @@ -745,6 +773,7 @@ class _DetailViewState extends State<_DetailView> title: widget.detail.title, sourceUrl: selection.url, videoHeight: selection.height, + type: selection.type, thumbnailUrl: widget.detail.thumbnail, headers: selection.headers, subtitles: subtitles, diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 9b493f2e..a38d82b3 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/player/media_segment_relay.dart'; import 'package:soplay/features/search/data/source_health_store.dart'; import 'package:soplay/features/download/presentation/widgets/download_choice_sheet.dart'; import 'package:share_plus/share_plus.dart'; diff --git a/lib/features/detail/presentation/pages/player_page.history.dart b/lib/features/detail/presentation/pages/player_page.history.dart index 5c7ac698..1510eddf 100644 --- a/lib/features/detail/presentation/pages/player_page.history.dart +++ b/lib/features/detail/presentation/pages/player_page.history.dart @@ -513,6 +513,7 @@ extension _PlayerHistory on _PlayerPageState { // would 403 on every host but the one already on screen. headers: selection.headers, videoHeight: selection.height, + type: selection.type, isSerial: widget.args.isSerial, episodeNumber: widget.args.isSerial && ep != null ? ep.episode : null, episodeLabel: ep?.label, diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 88dd3893..8ba1fd34 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -612,7 +612,9 @@ extension _PlayerMedia on _PlayerPageState { required bool hasDirective, }) => SourceLadder( sources: sources, - hasDirective: hasDirective, + hasDirective: + hasDirective || + sources.any((s) => s.type == 'iframe' || s.type == 'webview-extract'), rememberedQuality: SourceLadder.rememberedQualityFor( _titlePrefs, provider: widget.args.provider, @@ -841,6 +843,14 @@ extension _PlayerMedia on _PlayerPageState { // // The url is the one thing that is true at this point, so it is what the // lookup uses. + if (MediaSegmentRelay.needsManifestProxy(url)) { + final proxied = await getIt().register( + upstreamUrl: url, + headers: headers, + keepOriginHeaders: true, + ); + return _ProxiedTarget(url: proxied, headers: const {}); + } final source = _sourceForUrl(url); if (source == null) { _plog( @@ -989,10 +999,32 @@ extension _PlayerMedia on _PlayerPageState { } } - // Only when the server sent a directive — no provider check, no url - // pattern-matching. See `_extractorConfig`. - final cfg = _extractorConfig; - if (cfg != null && url.isNotEmpty && !_variantUrls.contains(url)) { + // Explicit embeds from older server builds also need device extraction. + // A direct stream in a mixed response must never be opened as a web page. + final cfg = + _extractorConfig ?? + (type?.toLowerCase() == 'iframe' || + type?.toLowerCase() == 'webview-extract' + ? ExtractorConfigEntity( + hostPattern: Uri.tryParse(url)?.host ?? '', + urlPatterns: const ['.m3u8', '.mpd', '.mp4'], + captureHeaders: const [ + 'Referer', + 'Origin', + 'User-Agent', + 'Cookie', + ], + ) + : null); + _extractorConfig ??= cfg; + final needsExtraction = + type == null || + type.toLowerCase() == 'iframe' || + type.toLowerCase() == 'webview-extract'; + if (cfg != null && + needsExtraction && + url.isNotEmpty && + !_variantUrls.contains(url)) { _plog( 'webview sniff: host=${cfg.hostPattern} patterns=${cfg.urlPatterns}', ); @@ -1007,7 +1039,7 @@ extension _PlayerMedia on _PlayerPageState { effUrl = sniffed.url; // Sniffed headers win: they are the ones the page actually sent, and // the CDN gates on exactly those. - effHeaders = {...headers, ...sniffed.headers}; + effHeaders = sniffed.headers; effType = sniffed.playType; _plog('sniff ok in ${sw.elapsedMilliseconds}ms -> $effUrl'); effUrl = await _applyRewrite(cfg.rewrite, effUrl, effHeaders); @@ -1046,20 +1078,15 @@ extension _PlayerMedia on _PlayerPageState { // menu, and it used to hold the first frame back for up to 23 s on a slow // CDN. Left running otherwise, it still fills the sheet when it lands. // - // On libmpv nothing is pinned by URL. The master plays, and the - // remembered height is picked from mpv's own renditions once they are - // known: swapping the master for one variant's playlist dropped audio - // that lives in a separate rendition, and turned every later quality - // change from an instant track switch into a full reload. + // Pin a requested height using the manifest rather than a guessed bitrate. + // Split audio is retained in the narrowed master served by the local proxy. final expansion = _maybeExpandQualities( effUrl, effHeaders, effType, generation, ); - final wantsUrlPin = - _qualityPreference != QualityPreference.auto && - !(_playsOnMpv && !_isDashType(effType, effUrl)); + final wantsUrlPin = _qualityPreference != QualityPreference.auto; VideoSourceEntity? pinned; if (wantsUrlPin) { pinned = await expansion.timeout( @@ -1164,7 +1191,7 @@ extension _PlayerMedia on _PlayerPageState { return _expandDash(parent, idx, url, headers, generation); } - final base = Uri.tryParse(url); + var base = Uri.tryParse(url); if (base == null) return null; String body; @@ -1183,6 +1210,7 @@ extension _PlayerMedia on _PlayerPageState { ); if (res.statusCode != 200) return null; body = res.data ?? ''; + base = res.realUri; } catch (_) { // A master that will not load is the player's problem to report, not // this one's — it is about to request the same url. @@ -1190,46 +1218,42 @@ extension _PlayerMedia on _PlayerPageState { } if (!body.trimLeft().startsWith('#EXTM3U')) return null; - // Audio (or subtitles) in renditions of their own: a variant's playlist - // then holds only the picture, and a row playing it had no sound. The - // master stays the only entry — ExoPlayer adapts across it, and on mpv - // the sheet lists the engine's own renditions instead. - if (RegExp( - r'#EXT-X-MEDIA:[^\n]*TYPE=AUDIO[^\n]*URI=', - ).hasMatch(body)) { - _plog('master has separate audio renditions — no per-variant rows'); - return null; + // Keep the master for separate audio/subtitles. Narrow only its video + // ladder, so a manual height cannot silently adapt down to 480p. + final separateRenditions = RegExp( + r'#EXT-X-MEDIA:[^\n]*URI=', + ).hasMatch(body); + final variants = bestPerHeight(parseHlsVariants(body, base)); + final rows = []; + for (final v in variants) { + final upHeaders = headers.isNotEmpty ? headers : parent.headers; + final selectedUrl = separateRenditions + ? await getIt().register( + upstreamUrl: url, + headers: upHeaders, + hlsHeight: v.height, + keepOriginHeaders: true, + localProxy: parent.localProxy, + requestTransform: parent.requestTransform, + longLived: true, + ) + : v.url; + rows.add( + VideoSourceEntity( + quality: '${parent.quality} · ${v.height}p', + videoUrl: selectedUrl, + isDefault: false, + accessible: parent.accessible, + height: v.height, + type: 'hls', + headers: upHeaders, + useLocalProxy: !separateRenditions && parent.useLocalProxy, + localProxy: parent.localProxy, + requestTransform: parent.requestTransform, + drm: parent.drm, + ), + ); } - - // Shared with the downloader, so the file saved matches the rendition the - // sheet offered. - final variants = parseHlsVariants(body, base); - - // One row per height: a master often carries the same resolution twice at - // different bitrates, which would list `1080p` twice. - final seen = {}; - final rows = [ - for (final v in variants) - if (v.height > 0 && seen.add(v.height)) - VideoSourceEntity( - quality: '${parent.quality} · ${v.height}p', - videoUrl: v.url, - isDefault: false, - accessible: parent.accessible, - height: v.height, - // Parsed out of an #EXTM3U master, whatever the parent was (an - // iframe entry, say): the format hint must say HLS. - type: 'hls', - // The headers the master was just fetched with: after a sniff - // those carry what the CDN gates on, which the parent's own - // page headers do not. - headers: headers.isNotEmpty ? headers : parent.headers, - useLocalProxy: parent.useLocalProxy, - localProxy: parent.localProxy, - requestTransform: parent.requestTransform, - drm: parent.drm, - ), - ]; return _insertQualityRows(parent, idx, rows, generation, 'master playlist'); } @@ -1342,17 +1366,6 @@ extension _PlayerMedia on _PlayerPageState { return rows.where((r) => r.height == want).firstOrNull; } - /// On libmpv the renditions are the engine's own tracks: the preferred - /// height is picked from them once they are listed. - /// This stream plays on libmpv, where a rendition is a track of the master - /// rather than a URL of its own. - bool get _playsOnMpv => - resolvePlayerEngine() == PlayerEngine.mediaKit && !_preferPlatformPlayer; - - bool _isDashType(String? type, String url) => - type?.trim().toLowerCase() == 'dash' || - url.toLowerCase().contains('.mpd'); - void _applyRememberedVideoTrack() { final c = _controller; // Once per controller, not once per episode: a quality or server switch, @@ -1561,26 +1574,9 @@ extension _PlayerMedia on _PlayerPageState { return; } if (controller.value.hasError) { - final raw = controller.value.errorDescription; - _plog('init error: $raw', level: LogLevel.error); - // The other half of the pair. Only the CATEGORY of failure travels — - // "codec", "network" — never the url or the message, which carry the - // token and the title. - getIt().track( - AnalyticsEvent.playbackFailed, - props: { - AnalyticsProp.provider: widget.args.provider, - AnalyticsProp.engine: resolvePlayerEngine().id, - AnalyticsProp.reason: _isCodecError ? 'codec' : 'load', - }, + throw StateError( + controller.value.errorDescription ?? 'Player initialization failed', ); - setState(() { - _initializing = false; - _errorMessage = raw == null - ? PlaybackFaultKind.unknown.messageKey.tr() - : _humanizeError(raw); - }); - return; } final dur = controller.value.duration; // What the caller said, OR what the duration implies. The declared type is @@ -1714,14 +1710,20 @@ extension _PlayerMedia on _PlayerPageState { // one of them already reads in the viewer's language. Unawaited and // silent unless it finds something: playback must never wait on it. unawaited(_maybeAutoTranslate()); - } on PlatformException catch (e) { - _plog( - 'platform exception ${e.code}: ${e.message}', - level: LogLevel.error, - ); + } catch (e) { + _plog('initialization failed: $e', level: LogLevel.error); if (!mounted || generation != _mediaGeneration) return; - final raw = e.message ?? ''; - if (e.code != 'channel-error' && + final code = e is PlatformException ? e.code : null; + final raw = e is PlatformException ? (e.message ?? e.code) : e.toString(); + getIt().track( + AnalyticsEvent.playbackFailed, + props: { + AnalyticsProp.provider: widget.args.provider, + AnalyticsProp.engine: resolvePlayerEngine().id, + AnalyticsProp.reason: _isDecoderError(raw) ? 'codec' : 'load', + }, + ); + if (code != 'channel-error' && !_isDecoderError(raw) && !_isLive && await _solveStreamCloudflare(generation)) { @@ -1729,7 +1731,7 @@ extension _PlayerMedia on _PlayerPageState { } if (!mounted || generation != _mediaGeneration) return; String msg; - if (e.code == 'channel-error') { + if (code == 'channel-error') { msg = PlaybackFaultKind.engineUnavailable.messageKey.tr(); } else if (_isDecoderError(raw)) { // The codec is the likeliest culprit, so siblings encoded the same way @@ -1814,13 +1816,6 @@ extension _PlayerMedia on _PlayerPageState { _errorMessage = msg; _errorRaw = raw; }); - } catch (e) { - _plog('init threw: $e', level: LogLevel.error); - if (!mounted || generation != _mediaGeneration) return; - setState(() { - _initializing = false; - _errorMessage = e.toString().replaceFirst('Exception: ', ''); - }); } } diff --git a/lib/features/detail/presentation/pages/player_page.panels.dart b/lib/features/detail/presentation/pages/player_page.panels.dart index 073637ad..3a23f32e 100644 --- a/lib/features/detail/presentation/pages/player_page.panels.dart +++ b/lib/features/detail/presentation/pages/player_page.panels.dart @@ -280,7 +280,7 @@ extension _PlayerPanels on _PlayerPageState { if (source == null) return 'player.quality'.tr(); final pinned = source.height ?? VideoOptionGroups.resolutionOf(source.quality); - if (pinned != null) return '${pinned}p'; + if (pinned != null) return '${playing ?? pinned}p'; return _hasParsedSiblings(source.quality) ? withPlaying : _qualityLabel(source.quality); @@ -423,341 +423,344 @@ extension _PlayerPanels on _PlayerPageState { // closing and reopening — see the aspect-ratio tile below. builder: (sheetContext) => StatefulBuilder( builder: (sheetContext, setSheetState) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: Row( - children: [ - const Icon( - Icons.settings_outlined, - color: Colors.white, - size: 18, - ), - const SizedBox(width: 10), - Text( - 'general.settings'.tr(), - style: const TextStyle( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), + child: Row( + children: [ + const Icon( + Icons.settings_outlined, color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w800, + size: 18, ), - ), - ], - ), - ), - const Divider(color: Colors.white12, height: 1), - // Switching provider without losing your place. - // - // The app carries the same title on several ecosystems at once, - // and until now the only way to leave a struggling source was to - // back out and start the episode again somewhere else. Offered - // here rather than only on the error screen, because a source - // that is buffering badly has not failed yet — that is exactly - // when someone wants out, and exactly when they have a position - // worth keeping. - // - // Not for live: a channel is one stream, and there is no other - // provider carrying the same minute of it. - if (!_isLive) - _SettingsTile( - icon: Icons.swap_horiz_rounded, - label: 'player.change_source'.tr(), - value: _providerLabel, - onTap: () { - Navigator.of(sheetContext).pop(); - _openAlternateSources(keepPosition: true); - }, + const SizedBox(width: 10), + Text( + 'general.settings'.tr(), + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ], + ), ), - // Speed, server, quality and subtitles used to sit here as well - // as on the bar, calling the very same functions. A settings list - // is where things go when they have nowhere else; those all have - // a button one tap away, so the copies were padding a list that - // was already nineteen rows long. Removed, not moved. - // - // Download is the exception and stays: the bar button for it is - // in the PHONE branch only, so on desktop this sheet is the only - // way to start one. Removing it took the feature off Windows, - // macOS and Linux entirely. - if (isDesktopPlatform && a.canDownload) + const Divider(color: Colors.white12, height: 1), + // Switching provider without losing your place. + // + // The app carries the same title on several ecosystems at once, + // and until now the only way to leave a struggling source was to + // back out and start the episode again somewhere else. Offered + // here rather than only on the error screen, because a source + // that is buffering badly has not failed yet — that is exactly + // when someone wants out, and exactly when they have a position + // worth keeping. + // + // Not for live: a channel is one stream, and there is no other + // provider carrying the same minute of it. + if (!_isLive) + _SettingsTile( + icon: Icons.swap_horiz_rounded, + label: 'player.change_source'.tr(), + value: _providerLabel, + onTap: () { + Navigator.of(sheetContext).pop(); + _openAlternateSources(keepPosition: true); + }, + ), + // Speed, server, quality and subtitles used to sit here as well + // as on the bar, calling the very same functions. A settings list + // is where things go when they have nowhere else; those all have + // a button one tap away, so the copies were padding a list that + // was already nineteen rows long. Removed, not moved. + // + // Download is the exception and stays: the bar button for it is + // in the PHONE branch only, so on desktop this sheet is the only + // way to start one. Removing it took the feature off Windows, + // macOS and Linux entirely. + if (isDesktopPlatform && a.canDownload) + _SettingsTile( + icon: Icons.download_rounded, + label: 'movie.download'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _startDownload(); + }, + ), + // Three values, stepped in place. + // + // This opened a SECOND sheet over the first to choose one of + // three — a whole modal, covering the video, for a setting whose + // entire interaction is "the next one". The row cycles now and + // shows where it landed, and the sheet it is in stays open so + // two steps cost two taps. _SettingsTile( - icon: Icons.download_rounded, - label: 'movie.download'.tr(), - value: '', + icon: Icons.aspect_ratio_rounded, + label: 'player.aspect'.tr(), + value: _fitLabel(_fit), onTap: () { - Navigator.of(sheetContext).pop(); - _startDownload(); + _cycleFit(toast: false); + setSheetState(() {}); }, ), - // Three values, stepped in place. - // - // This opened a SECOND sheet over the first to choose one of - // three — a whole modal, covering the video, for a setting whose - // entire interaction is "the next one". The row cycles now and - // shows where it landed, and the sheet it is in stays open so - // two steps cost two taps. - _SettingsTile( - icon: Icons.aspect_ratio_rounded, - label: 'player.aspect'.tr(), - value: _fitLabel(_fit), - onTap: () { - _cycleFit(toast: false); - setSheetState(() {}); - }, - ), - if (!isDesktopPlatform && - !_preferPlatformPlayer && - (_controller?.supportsVideoTracks ?? false)) + if (!isDesktopPlatform && + !_preferPlatformPlayer && + (_controller?.supportsVideoTracks ?? false)) + _SettingsTile( + icon: Icons.play_circle_outline, + label: 'profile.player_engine_native'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _playWithSystemPlayer(); + }, + ), + // Absent on the platform player, which has no runtime picture + // control at all. Offering a menu that silently does nothing + // teaches people the app is broken rather than that the feature + // is unavailable here. + if (_controller?.supportsColorProfile ?? false) + _SettingsTile( + icon: Icons.tune_rounded, + label: 'player.color_profile'.tr(), + value: _colorProfile.labelKey.tr(), + onTap: () { + Navigator.of(sheetContext).pop(); + _openColorSheet(); + }, + ), + // Only on libmpv, which is the one backend that can read a frame + // back. The platform player and the DRM one keep their pixels in + // a texture the Dart side never sees, so the row is absent there + // rather than present and producing nothing. + // + // Live has no frame worth sharing and no title to attach it to. + if (!_isLive && (_controller?.supportsShaders ?? false)) + _SettingsTile( + icon: Icons.photo_camera_outlined, + label: 'player.share_frame'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _shareCurrentFrame(); + }, + ), + // Sharing and keeping are different intentions, and routing + // "keep" through the share sheet made it a three-tap detour via + // whichever app the system decided could save a file. The frame + // people wanted was also the one the system screenshot key could + // not give them: that one carries the controls, the seek bar and + // the status bar burnt into it, at the screen's resolution + // rather than the video's. + if (!_isLive && (_controller?.supportsShaders ?? false)) + _SettingsTile( + icon: Icons.save_alt_rounded, + label: 'player.save_frame'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _saveCurrentFrame(); + }, + ), + // Always listed, disabled where it cannot run. + // + // It used to disappear entirely on the platform player, which is + // the default on most devices — so Anime4K, sharpen, deblur and + // denoise were invisible to the people most likely to want them, + // with nothing on screen to say the feature existed or why it was + // absent. A row that explains itself beats a row that is not + // there. + if (_controller?.supportsShaders ?? false) + _SettingsTile( + icon: Icons.auto_awesome_rounded, + label: 'player.shaders'.tr(), + value: _shaderPreset.isOff + ? 'general.off'.tr() + : _shaderPreset.labelKey.tr(), + onTap: () { + Navigator.of(sheetContext).pop(); + _openShaderSheet(); + }, + ) + else + _SettingsTile( + icon: Icons.auto_awesome_rounded, + label: 'player.shaders'.tr(), + value: _mediaKitMissing(), + onTap: null, + ), + // Shown here, and not only in Settings, so a mode that suppresses + // history cannot sit on unnoticed for weeks: the sheet is the one + // surface a viewer opens mid-episode, and this is where they will + // wonder why nothing is in Continue Watching. + if (_hive.isIncognito) + _SettingsTile( + icon: Icons.visibility_off_outlined, + label: 'profile.incognito'.tr(), + value: 'general.on'.tr(), + onTap: () { + Navigator.of(sheetContext).pop(); + _hive.setIncognito(false); + if (!mounted) return; + setState(() {}); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('profile.incognito_off'.tr()), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + }, + ), + // Hidden rather than disabled when the stream cannot be cast — a + // torrent handle or a downloaded file has no address a device on + // the network could fetch, and a greyed-out row invites a tap + // that can only ever explain itself. + if (_canCast) + _SettingsTile( + icon: _cast.isCasting + ? Icons.cast_connected_rounded + : Icons.cast_rounded, + label: 'player.cast'.tr(), + value: _cast.device?.name ?? '—', + onTap: () { + Navigator.of(sheetContext).pop(); + if (_cast.isCasting) { + _stopCasting(); + } else { + _openCastSheet(); + } + }, + ), + // Not offered for live TV: a broadcast has no end to stop at, and + // a countdown that pauses a channel is just a mute button with + // extra steps. + if (!_isLive) + _SettingsTile( + icon: Icons.bedtime_outlined, + label: 'player.sleep_timer'.tr(), + value: _sleepValueLabel, + onTap: () { + Navigator.of(sheetContext).pop(); + _openSleepSheet(); + }, + ), + if (hasLangs) + _SettingsTile( + icon: Icons.translate_rounded, + label: 'player.audio_language'.tr(), + value: _currentLang == null + ? '—' + : _langLabel(_currentLang!), + onTap: () { + Navigator.of(sheetContext).pop(); + _openLangSheet(); + }, + ), + if (_subtitles.isNotEmpty || + (_controller?.subtitleTracks.isNotEmpty ?? false)) + _SettingsTile( + icon: Icons.text_fields_rounded, + label: 'player.subtitle_style'.tr(), + value: '${_subtitleStyle.fontSize.round()}px', + onTap: () { + Navigator.of(sheetContext).pop(); + _openSubtitleAppearanceSheet(); + }, + ), + // Audio tracks are a media_kit capability: video_player exposes no + // track-selection API at all. Under the default engine the tile + // stays disabled and says why, rather than opening an empty sheet. + if (!hasLangs) + Builder( + builder: (_) { + final c = _controller; + final supported = c?.supportsAudioTracks ?? false; + final tracks = + c?.audioTracks ?? const []; + if (!supported) { + return _SettingsTile( + icon: Icons.audiotrack_outlined, + label: 'player.audio_track'.tr(), + value: _mediaKitMissing(onlyLabel: true), + onTap: null, + ); + } + return _SettingsTile( + icon: Icons.audiotrack_outlined, + label: 'player.audio_track'.tr(), + value: tracks.length < 2 + ? 'player.audio_track_single'.tr() + : _activeAudioTrackLabel(), + onTap: tracks.length < 2 + ? null + : () { + Navigator.of(sheetContext).pop(); + _openAudioTrackSheet(); + }, + ); + }, + ), + if (ExternalPlayer.isSupported && _videoUrl != null) + _SettingsTile( + icon: Icons.open_in_new_rounded, + label: 'player.external_player'.tr(), + value: '', + onTap: () { + Navigator.of(sheetContext).pop(); + _handOffToExternalPlayer(); + }, + ), + // Was `showDownloadAction && provider != 'uzmovi'` — the url + // Beside the diagnostics log, which answers the same question + // for whoever wrote the player. This one answers it for whoever + // is watching — and is small enough to screenshot into a report. _SettingsTile( - icon: Icons.play_circle_outline, - label: 'profile.player_engine_native'.tr(), - value: '', + icon: Icons.info_outline_rounded, + label: 'player.info_title'.tr(), + value: _showPlayerInfo ? 'player.on'.tr() : 'player.off'.tr(), onTap: () { Navigator.of(sheetContext).pop(); - _playWithSystemPlayer(); + setState(() => _showPlayerInfo = !_showPlayerInfo); }, ), - // Absent on the platform player, which has no runtime picture - // control at all. Offering a menu that silently does nothing - // teaches people the app is broken rather than that the feature - // is unavailable here. - if (_controller?.supportsColorProfile ?? false) _SettingsTile( icon: Icons.tune_rounded, - label: 'player.color_profile'.tr(), - value: _colorProfile.labelKey.tr(), - onTap: () { - Navigator.of(sheetContext).pop(); - _openColorSheet(); - }, - ), - // Only on libmpv, which is the one backend that can read a frame - // back. The platform player and the DRM one keep their pixels in - // a texture the Dart side never sees, so the row is absent there - // rather than present and producing nothing. - // - // Live has no frame worth sharing and no title to attach it to. - if (!_isLive && (_controller?.supportsShaders ?? false)) - _SettingsTile( - icon: Icons.photo_camera_outlined, - label: 'player.share_frame'.tr(), + label: 'player.info_fields_title'.tr(), value: '', onTap: () { Navigator.of(sheetContext).pop(); - _shareCurrentFrame(); + _openInfoFieldsPicker(); }, ), - // Sharing and keeping are different intentions, and routing - // "keep" through the share sheet made it a three-tap detour via - // whichever app the system decided could save a file. The frame - // people wanted was also the one the system screenshot key could - // not give them: that one carries the controls, the seek bar and - // the status bar burnt into it, at the screen's resolution - // rather than the video's. - if (!_isLive && (_controller?.supportsShaders ?? false)) _SettingsTile( - icon: Icons.save_alt_rounded, - label: 'player.save_frame'.tr(), + icon: Icons.dashboard_customize_outlined, + label: 'player.layout_title'.tr(), value: '', onTap: () { Navigator.of(sheetContext).pop(); - _saveCurrentFrame(); + _openControlsLayoutPage(); }, ), - // Always listed, disabled where it cannot run. - // - // It used to disappear entirely on the platform player, which is - // the default on most devices — so Anime4K, sharpen, deblur and - // denoise were invisible to the people most likely to want them, - // with nothing on screen to say the feature existed or why it was - // absent. A row that explains itself beats a row that is not - // there. - if (_controller?.supportsShaders ?? false) - _SettingsTile( - icon: Icons.auto_awesome_rounded, - label: 'player.shaders'.tr(), - value: _shaderPreset.isOff - ? 'general.off'.tr() - : _shaderPreset.labelKey.tr(), - onTap: () { - Navigator.of(sheetContext).pop(); - _openShaderSheet(); - }, - ) - else _SettingsTile( - icon: Icons.auto_awesome_rounded, - label: 'player.shaders'.tr(), - value: _mediaKitMissing(), - onTap: null, - ), - // Shown here, and not only in Settings, so a mode that suppresses - // history cannot sit on unnoticed for weeks: the sheet is the one - // surface a viewer opens mid-episode, and this is where they will - // wonder why nothing is in Continue Watching. - if (_hive.isIncognito) - _SettingsTile( - icon: Icons.visibility_off_outlined, - label: 'profile.incognito'.tr(), - value: 'general.on'.tr(), - onTap: () { - Navigator.of(sheetContext).pop(); - _hive.setIncognito(false); - if (!mounted) return; - setState(() {}); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('profile.incognito_off'.tr()), - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), - ); - }, - ), - // Hidden rather than disabled when the stream cannot be cast — a - // torrent handle or a downloaded file has no address a device on - // the network could fetch, and a greyed-out row invites a tap - // that can only ever explain itself. - if (_canCast) - _SettingsTile( - icon: _cast.isCasting - ? Icons.cast_connected_rounded - : Icons.cast_rounded, - label: 'player.cast'.tr(), - value: _cast.device?.name ?? '—', - onTap: () { - Navigator.of(sheetContext).pop(); - if (_cast.isCasting) { - _stopCasting(); - } else { - _openCastSheet(); - } - }, - ), - // Not offered for live TV: a broadcast has no end to stop at, and - // a countdown that pauses a channel is just a mute button with - // extra steps. - if (!_isLive) - _SettingsTile( - icon: Icons.bedtime_outlined, - label: 'player.sleep_timer'.tr(), - value: _sleepValueLabel, - onTap: () { - Navigator.of(sheetContext).pop(); - _openSleepSheet(); - }, - ), - if (hasLangs) - _SettingsTile( - icon: Icons.translate_rounded, - label: 'player.audio_language'.tr(), - value: _currentLang == null ? '—' : _langLabel(_currentLang!), - onTap: () { - Navigator.of(sheetContext).pop(); - _openLangSheet(); - }, - ), - if (_subtitles.isNotEmpty || - (_controller?.subtitleTracks.isNotEmpty ?? false)) - _SettingsTile( - icon: Icons.text_fields_rounded, - label: 'player.subtitle_style'.tr(), - value: '${_subtitleStyle.fontSize.round()}px', - onTap: () { - Navigator.of(sheetContext).pop(); - _openSubtitleAppearanceSheet(); - }, - ), - // Audio tracks are a media_kit capability: video_player exposes no - // track-selection API at all. Under the default engine the tile - // stays disabled and says why, rather than opening an empty sheet. - if (!hasLangs) - Builder( - builder: (_) { - final c = _controller; - final supported = c?.supportsAudioTracks ?? false; - final tracks = c?.audioTracks ?? const []; - if (!supported) { - return _SettingsTile( - icon: Icons.audiotrack_outlined, - label: 'player.audio_track'.tr(), - value: _mediaKitMissing(onlyLabel: true), - onTap: null, - ); - } - return _SettingsTile( - icon: Icons.audiotrack_outlined, - label: 'player.audio_track'.tr(), - value: tracks.length < 2 - ? 'player.audio_track_single'.tr() - : _activeAudioTrackLabel(), - onTap: tracks.length < 2 - ? null - : () { - Navigator.of(sheetContext).pop(); - _openAudioTrackSheet(); - }, - ); - }, - ), - if (ExternalPlayer.isSupported && _videoUrl != null) - _SettingsTile( - icon: Icons.open_in_new_rounded, - label: 'player.external_player'.tr(), + icon: Icons.bug_report_outlined, + label: 'player.diagnostics_logs'.tr(), value: '', onTap: () { Navigator.of(sheetContext).pop(); - _handOffToExternalPlayer(); + LogViewerSheet.show(context); }, ), - // Was `showDownloadAction && provider != 'uzmovi'` — the url - // Beside the diagnostics log, which answers the same question - // for whoever wrote the player. This one answers it for whoever - // is watching — and is small enough to screenshot into a report. - _SettingsTile( - icon: Icons.info_outline_rounded, - label: 'player.info_title'.tr(), - value: _showPlayerInfo ? 'player.on'.tr() : 'player.off'.tr(), - onTap: () { - Navigator.of(sheetContext).pop(); - setState(() => _showPlayerInfo = !_showPlayerInfo); - }, - ), - _SettingsTile( - icon: Icons.tune_rounded, - label: 'player.info_fields_title'.tr(), - value: '', - onTap: () { - Navigator.of(sheetContext).pop(); - _openInfoFieldsPicker(); - }, - ), - _SettingsTile( - icon: Icons.dashboard_customize_outlined, - label: 'player.layout_title'.tr(), - value: '', - onTap: () { - Navigator.of(sheetContext).pop(); - _openControlsLayoutPage(); - }, - ), - _SettingsTile( - icon: Icons.bug_report_outlined, - label: 'player.diagnostics_logs'.tr(), - value: '', - onTap: () { - Navigator.of(sheetContext).pop(); - LogViewerSheet.show(context); - }, - ), - const SizedBox(height: 8), - ], + const SizedBox(height: 8), + ], + ), ), ), ), - ), ); } diff --git a/lib/features/download/data/datasources/download_native_data_source.dart b/lib/features/download/data/datasources/download_native_data_source.dart index 7b4d46fd..1520df13 100644 --- a/lib/features/download/data/datasources/download_native_data_source.dart +++ b/lib/features/download/data/datasources/download_native_data_source.dart @@ -1,3 +1,4 @@ +import 'package:soplay/core/constants/app_constants.dart'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -100,6 +101,7 @@ class DownloadNativeDataSource { required List pageUrls, List> imageHeaders = const [], required bool wifiOnly, + int? videoHeight, }) async { if (!isSupported) return false; try { @@ -113,6 +115,8 @@ class DownloadNativeDataSource { 'pageUrls': pageUrls, 'imageHeaders': imageHeaders, 'wifiOnly': wifiOnly, + 'videoHeight': videoHeight, + 'segmentRelay': '${AppConstants.baseUrl}/contents/media-segment', }) ?? false; } on PlatformException catch (e) { diff --git a/lib/features/download/data/datasources/download_transfer_data_source.dart b/lib/features/download/data/datasources/download_transfer_data_source.dart index 82ba0bd1..d0ee5b5d 100644 --- a/lib/features/download/data/datasources/download_transfer_data_source.dart +++ b/lib/features/download/data/datasources/download_transfer_data_source.dart @@ -124,6 +124,7 @@ class DownloadTransferDataSource { required String dirPath, required DownloadKind kind, required String sourceUrl, + int? videoHeight, required Map headers, required List pageUrls, String? chapterHtml, @@ -142,6 +143,7 @@ class DownloadTransferDataSource { onProgress: onProgress, ), DownloadKind.hls => await _hls( + videoHeight: videoHeight, dirPath: dirPath, url: sourceUrl, headers: headers, @@ -151,23 +153,24 @@ class DownloadTransferDataSource { // One kind, two shapes. A comic chapter is a folder of images; a // novel chapter is one document, whose pictures are fetched into the // same folder so the words and the illustrations are deleted together. - DownloadKind.manga => (chapterHtml ?? '').trim().isNotEmpty - ? await _prose( - dirPath: dirPath, - html: chapterHtml!, - sourceUrl: sourceUrl, - headers: headers, - cancel: cancel, - onProgress: onProgress, - ) - : await _pages( - dirPath: dirPath, - pageUrls: pageUrls, - imageHeaders: imageHeaders, - headers: headers, - cancel: cancel, - onProgress: onProgress, - ), + DownloadKind.manga => + (chapterHtml ?? '').trim().isNotEmpty + ? await _prose( + dirPath: dirPath, + html: chapterHtml!, + sourceUrl: sourceUrl, + headers: headers, + cancel: cancel, + onProgress: onProgress, + ) + : await _pages( + dirPath: dirPath, + pageUrls: pageUrls, + imageHeaders: imageHeaders, + headers: headers, + cancel: cancel, + onProgress: onProgress, + ), }; } on _NotMediaException catch (e) { return TransferResult.failed(DownloadFailureKind.notMedia, e.message); @@ -375,6 +378,7 @@ class DownloadTransferDataSource { // --- playlist ------------------------------------------------------------ Future _hls({ + int? videoHeight, required String dirPath, required String url, required Map headers, @@ -387,7 +391,7 @@ class DownloadTransferDataSource { ({String url, String mediaTag, String streamTag})? audio; if (playlist.contains('#EXT-X-STREAM-INF')) { - final variant = _pickVariant(playlist, _baseOf(url)); + final variant = _pickVariant(playlist, _baseOf(url), videoHeight); if (variant == null) { return const TransferResult.failed( DownloadFailureKind.notMedia, @@ -1086,11 +1090,11 @@ class DownloadTransferDataSource { /// /// Nobody downloads a film in order to watch the smallest copy of it, so the /// best rendition is the answer rather than a setting. - String? _pickVariant(String playlist, String base) { + String? _pickVariant(String playlist, String base, int? height) { final uri = Uri.tryParse(base); if (uri == null) return null; - final variants = parseHlsVariants(playlist, uri); - if (variants.isNotEmpty) return variants.first.url; + final variant = selectHlsVariant(playlist, uri, height); + if (variant != null) return variant.url; // A master whose variants state neither a name nor a RESOLUTION: the // highest bitrate that carries a picture. "First" was the smallest here diff --git a/lib/features/download/data/repositories/download_repository_impl.dart b/lib/features/download/data/repositories/download_repository_impl.dart index fc24cb03..d27395dc 100644 --- a/lib/features/download/data/repositories/download_repository_impl.dart +++ b/lib/features/download/data/repositories/download_repository_impl.dart @@ -523,6 +523,7 @@ class DownloadRepositoryImpl implements DownloadRepository { pageUrls: item.pageUrls, imageHeaders: item.imageHeaders, wifiOnly: _hive.downloadWifiOnly, + videoHeight: item.videoHeight, ); if (!started) { await _fail( @@ -553,6 +554,7 @@ class DownloadRepositoryImpl implements DownloadRepository { dirPath: dir.path, kind: item.kind, sourceUrl: item.sourceUrl, + videoHeight: item.videoHeight, headers: item.headers, pageUrls: item.pageUrls, chapterHtml: item.chapterHtml, diff --git a/lib/features/download/domain/entities/download_request.dart b/lib/features/download/domain/entities/download_request.dart index fbc02071..a952de44 100644 --- a/lib/features/download/domain/entities/download_request.dart +++ b/lib/features/download/domain/entities/download_request.dart @@ -45,6 +45,7 @@ class DownloadRequest { required String sourceUrl, String? thumbnailUrl, int? videoHeight, + String? type, Map headers = const {}, bool isSerial = false, int? episodeNumber, @@ -57,7 +58,9 @@ class DownloadRequest { provider: provider, title: title, sourceUrl: sourceUrl, - kind: DownloadKind.fromLegacy('video', sourceUrl), + kind: type == 'hls' || type == 'm3u8' + ? DownloadKind.hls + : DownloadKind.fromLegacy('video', sourceUrl), videoHeight: videoHeight, thumbnailUrl: thumbnailUrl, headers: headers, diff --git a/lib/features/download/domain/entities/download_selection.dart b/lib/features/download/domain/entities/download_selection.dart index a398e1e1..b00cd56f 100644 --- a/lib/features/download/domain/entities/download_selection.dart +++ b/lib/features/download/domain/entities/download_selection.dart @@ -4,8 +4,10 @@ class DownloadSelection { required this.url, required this.headers, this.height, + this.type, }); final String url; final Map headers; final int? height; + final String? type; } diff --git a/lib/features/download/domain/usecases/download_request_builder.dart b/lib/features/download/domain/usecases/download_request_builder.dart index e86cf326..09e3bdd1 100644 --- a/lib/features/download/domain/usecases/download_request_builder.dart +++ b/lib/features/download/domain/usecases/download_request_builder.dart @@ -66,11 +66,18 @@ class DownloadRequestBuilder { const DownloadRequestBuilder({ required ResolveMediaUseCase resolve, required GetPagesUseCase getPages, + Future Function(MediaResolveEntity)? resolveEmbed, + Future Function(MediaResolveEntity)? canDownload, }) : _resolve = resolve, - _getPages = getPages; + _getPages = getPages, + _resolveEmbed = resolveEmbed, + _canDownload = canDownload; final ResolveMediaUseCase _resolve; final GetPagesUseCase _getPages; + final Future Function(MediaResolveEntity)? _resolveEmbed; + + final Future Function(MediaResolveEntity)? _canDownload; /// The stream URL does not exist until the provider is asked for it, so a /// video download always starts with the same resolve the player makes. @@ -83,17 +90,85 @@ class DownloadRequestBuilder { result.value.videoUrl.isEmpty) { return const VideoResolution.failed(DownloadBuildFailure.resolveFailed); } - final media = result.value; - // A directive means `videoUrl` is the embed PAGE. Saving it would produce - // an HTML file under a video's name that fails on first open. - if (!DownloadChoices.isDownloadableUrl( - url: media.videoUrl, - type: media.type, - hasDirective: media.extractor != null, - )) { - return const VideoResolution.failed(DownloadBuildFailure.needsPlayback); + return prepareVideo(result.value); + } + + /// Both film and episode downloads must turn an embed into media first. + Future prepareVideo(MediaResolveEntity media) async { + bool direct(MediaResolveEntity item) => DownloadChoices.isDownloadableUrl( + url: item.videoUrl, + type: item.type, + hasDirective: item.extractor != null, + ); + // Plain direct downloads keep their existing path. Mixed embed/direct + // results need validation: backend-issued tokens can be IP-bound or stale. + final hasEmbeds = + !direct(media) || + media.videoSources.any( + (s) => !DownloadChoices.isDownloadableUrl( + url: s.videoUrl, + type: s.type, + hasDirective: false, + ), + ); + if (!hasEmbeds) return VideoResolution.resolved(media); + + final candidates = []; + if (direct(media)) candidates.add(media); + final ladder = SourceLadder( + sources: media.videoSources, + hasDirective: false, + ); + final preferred = ladder.initialPick(); + final order = [ + ?preferred, + for (var i = 0; i < media.videoSources.length; i++) + if (i != preferred) i, + ]; + for (final i in order) { + final source = media.videoSources[i]; + candidates.add( + MediaResolveEntity( + videoUrl: source.videoUrl, + type: source.type, + headers: source.headers.isEmpty ? media.headers : source.headers, + subtitles: media.subtitles, + videoSources: [source], + ), + ); + } + if (!direct(media)) candidates.insert(0, media); + // Direct candidates first, then each embed. Never let quietPick re-select + // an unvalidated sibling from the original response after a fallback. + final ordered = [ + ...candidates.where(direct), + ...candidates.where((c) => !direct(c)), + ]; + final seen = {}; + for (final candidate in ordered) { + if (!seen.add(candidate.videoUrl)) continue; + try { + final resolved = direct(candidate) + ? candidate + : await _resolveEmbed?.call(candidate); + if (resolved == null || !direct(resolved)) continue; + if (_canDownload != null && !await _canDownload(resolved)) continue; + return VideoResolution.resolved( + MediaResolveEntity( + videoUrl: resolved.videoUrl, + type: resolved.type, + headers: resolved.headers, + subtitles: resolved.subtitles, + videoSources: resolved.videoSources + .where((s) => s.videoUrl == resolved.videoUrl) + .toList(), + ), + ); + } catch (_) { + /* This mirror failed; try the next candidate. */ + } } - return VideoResolution.resolved(media); + return const VideoResolution.failed(DownloadBuildFailure.needsPlayback); } /// The mirror a download takes when nobody is asked. @@ -107,13 +182,18 @@ class DownloadRequestBuilder { ? null : SourceLadder(sources: sources, hasDirective: false).initialPick(); if (pick == null) { - return DownloadSelection(url: media.videoUrl, headers: media.headers); + return DownloadSelection( + url: media.videoUrl, + headers: media.headers, + type: media.type, + ); } final source = sources[pick]; return DownloadSelection( url: source.videoUrl, headers: source.headers.isNotEmpty ? source.headers : media.headers, height: source.height, + type: source.type, ); } @@ -128,6 +208,7 @@ class DownloadRequestBuilder { title: title.title, sourceUrl: selection.url, videoHeight: selection.height, + type: selection.type, thumbnailUrl: title.thumbnail, headers: selection.headers, isSerial: true, diff --git a/lib/features/download/presentation/widgets/download_choice_sheet.dart b/lib/features/download/presentation/widgets/download_choice_sheet.dart index d75e703d..811a3473 100644 --- a/lib/features/download/presentation/widgets/download_choice_sheet.dart +++ b/lib/features/download/presentation/widgets/download_choice_sheet.dart @@ -90,6 +90,7 @@ class _DownloadChoiceSheetState extends State { matching?.height ?? VideoOptionGroups.resolutionOf(matching?.quality ?? ''), sizeBytes: matching?.sizeBytes, + signer: matching?.signer, ), ]; final seen = {widget.url}; @@ -309,7 +310,7 @@ class _DownloadChoiceSheetState extends State { // A file the host signs per device (asilmedia): // fetched unsigned it is a 403, so sign it first, // with the headers the download will send. - var url = variant?.url ?? source.videoUrl; + var url = source.videoUrl; final signer = source.signer; if (variant == null && signer != null) { setState(() => _loading = true); @@ -321,6 +322,7 @@ class _DownloadChoiceSheetState extends State { DownloadSelection( url: url, headers: source.headers, + type: variant != null ? 'hls' : source.type, // The label is a last resort, but it is a real // one: plenty of sources declare no height and // call the rendition "1080p" in words. Without diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 005a245b..f0173e39 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -150,6 +150,8 @@ PODS: - FlutterMacOS - flutter_timezone (0.1.0): - FlutterMacOS + - flutter_tts (0.0.1): + - FlutterMacOS - FlutterMacOS (1.0.0) - gal (1.0.0): - Flutter @@ -270,6 +272,8 @@ PODS: - FlutterMacOS - window_manager (0.2.0): - FlutterMacOS + - workmanager_apple (0.0.1): + - FlutterMacOS DEPENDENCIES: - amplitude_flutter (from `Flutter/ephemeral/.symlinks/plugins/amplitude_flutter/darwin`) @@ -287,6 +291,7 @@ DEPENDENCIES: - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`) + - flutter_tts (from `Flutter/ephemeral/.symlinks/plugins/flutter_tts/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`) - google_sign_in_ios (from `Flutter/ephemeral/.symlinks/plugins/google_sign_in_ios/darwin`) @@ -305,6 +310,7 @@ DEPENDENCIES: - volume_controller (from `Flutter/ephemeral/.symlinks/plugins/volume_controller/macos`) - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) + - workmanager_apple (from `Flutter/ephemeral/.symlinks/plugins/workmanager_apple/macos`) SPEC REPOS: trunk: @@ -370,6 +376,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos flutter_timezone: :path: Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos + flutter_tts: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_tts/macos FlutterMacOS: :path: Flutter/ephemeral gal: @@ -406,6 +414,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos window_manager: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos + workmanager_apple: + :path: Flutter/ephemeral/.symlinks/plugins/workmanager_apple/macos SPEC CHECKSUMS: amplitude_flutter: a430a891abbd9e486f53d2ab499af9ead0bb8047 @@ -443,6 +453,7 @@ SPEC CHECKSUMS: flutter_local_notifications: 7e5a17a1dbc00d83dc10d43c2c4c05f2ceed233c flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 flutter_timezone: bed4b9a391e5727a612e722d7f518882c5da205d + flutter_tts: ae915565cc6948444b513acc8ee021993281e027 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 gal: baecd024ebfd13c441269ca7404792a7152fde89 google_sign_in_ios: 000870aa06da9b28d1d0bf7ef70ff0213059dd28 @@ -471,6 +482,7 @@ SPEC CHECKSUMS: volume_controller: 5c068e6d085c80dadd33fc2c918d2114b775b3dd wakelock_plus: 917609be14d812ddd9e9528876538b2263aaa03b window_manager: 1d01fa7ac65a6e6f83b965471b1a7fdd3f06166c + workmanager_apple: 573f1503f9752eadd70a35bb478dfd2a103c4d18 PODFILE CHECKSUM: 6f088238eaacabf3696af194a28686668e21866b diff --git a/test/core/player/download_media_probe_test.dart b/test/core/player/download_media_probe_test.dart new file mode 100644 index 00000000..327a825a --- /dev/null +++ b/test/core/player/download_media_probe_test.dart @@ -0,0 +1,64 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/player/download_media_probe.dart'; +import 'package:soplay/features/detail/domain/entities/media_resolve_entity.dart'; + +void main() { + test( + 'probe rejects expired token and HTML, accepts real HLS and MP4 with wrong MIME', + () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.listen((req) async { + switch (req.uri.path) { + case '/expired': + req.response.statusCode = 403; + break; + case '/html': + req.response.write('Access denied'); + break; + case '/playlist': + req.response.write( + '#EXTM3U\n#EXT-X-TARGETDURATION:10\n#EXTINF:10,\nseg.ts\n', + ); + break; + case '/video': + req.response.headers.contentType = ContentType('image', 'jpeg'); + req.response.add([ + 0, + 0, + 0, + 24, + 102, + 116, + 121, + 112, + 109, + 112, + 52, + 50, + ]); + } + await req.response.close(); + }); + final dio = Dio(); + final probe = DownloadMediaProbe(dio); + try { + Future check(String path, String type) => probe.accepts( + MediaResolveEntity( + videoUrl: 'http://127.0.0.1:${server.port}/$path', + headers: const {}, + type: type, + ), + ); + expect(await check('expired', 'hls'), isFalse); + expect(await check('html', 'hls'), isFalse); + expect(await check('playlist', 'hls'), isTrue); + expect(await check('video', 'mp4'), isTrue); + } finally { + dio.close(force: true); + await server.close(force: true); + } + }, + ); +} diff --git a/test/core/player/extracted_media_headers_test.dart b/test/core/player/extracted_media_headers_test.dart new file mode 100644 index 00000000..7780b57c --- /dev/null +++ b/test/core/player/extracted_media_headers_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/player/extracted_media_headers.dart'; + +void main() { + test('media uses embed context rather than catalog navigation headers', () { + final headers = extractedMediaHeaders( + pageUrl: 'https://embed.test/e/123', + userAgent: 'Device WebView', + pageHeaders: const { + 'User-Agent': 'Old backend agent', + 'Referer': 'https://catalog.test/', + 'Origin': 'https://catalog.test', + 'X-Token': 'token', + }, + capturedHeaders: const {}, + captureHeaders: const ['User-Agent', 'Referer', 'Origin'], + ); + expect(headers['User-Agent'], 'Device WebView'); + expect(headers['Referer'], 'https://embed.test/'); + expect(headers['Origin'], 'https://embed.test'); + expect(headers['X-Token'], 'token'); + }); + test( + 'captured headers are preserved case-insensitively without duplicates', + () { + final headers = extractedMediaHeaders( + pageUrl: 'https://embed.test/e/123', + userAgent: 'Device WebView', + pageHeaders: const { + 'User-Agent': 'Old agent', + 'Referer': 'https://catalog.test/', + }, + capturedHeaders: const { + 'user-agent': 'Captured agent', + 'referer': 'https://frame.test/', + 'origin': 'https://frame.test', + 'x-token': 'one-shot', + }, + captureHeaders: const ['User-Agent', 'Referer', 'Origin'], + ); + expect(headers['user-agent'], 'Captured agent'); + expect(headers['referer'], 'https://frame.test/'); + expect(headers['origin'], 'https://frame.test'); + expect( + headers.keys.where((k) => k.toLowerCase() == 'user-agent').length, + 1, + ); + expect(headers.keys.where((k) => k.toLowerCase() == 'referer').length, 1); + expect(headers['x-token'], 'one-shot'); + }, + ); +} diff --git a/test/core/player/hls_variants_test.dart b/test/core/player/hls_variants_test.dart index fd33a19e..50e81459 100644 --- a/test/core/player/hls_variants_test.dart +++ b/test/core/player/hls_variants_test.dart @@ -5,6 +5,24 @@ const _base = 'https://cdn.example.test/hls/master.m3u8'; void main() { final base = Uri.parse(_base); + test('manual height uses resolution even at a higher-than-typical bitrate', () { + const master = + '#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="a",URI="audio.m3u8"\n' + '#EXT-X-STREAM-INF:BANDWIDTH=12000000,RESOLUTION=1280x720,AUDIO="a"\n720.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=18000000,RESOLUTION=1920x1080,AUDIO="a"\n1080.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=854x480,AUDIO="a"\n480.m3u8\n'; + expect(selectHlsVariant(master, base, 720)?.url, endsWith('/720.m3u8')); + final pinned = narrowHlsMaster(master, base, 720); + expect(parseHlsVariants(pinned, base).single.height, 720); + expect(pinned, contains('URI="audio.m3u8"')); + expect(pinned, isNot(contains('480.m3u8'))); + }); + + test('a filename cannot advertise 1080p for measured 480p', () { + const master = + '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=854x480\nindex-s1080p.m3u8\n'; + expect(parseHlsVariants(master, base).single.height, 480); + }); test('the best rendition wins, not the first one listed', () { // The shape that caused the bug: packagers list the lowest bitrate first so @@ -53,7 +71,10 @@ high/playlist.m3u8 final variants = parseHlsVariants(master, base); expect(variants.first.height, 1080); - expect(variants.first.url, 'https://cdn.example.test/hls/high/playlist.m3u8'); + expect( + variants.first.url, + 'https://cdn.example.test/hls/high/playlist.m3u8', + ); }); test('relative and absolute variant uris both resolve', () { @@ -80,12 +101,16 @@ https://other.example.test/v/1080.m3u8 index-s1080p-v1-a1.m3u8 '''; final fromMaster = parseHlsVariants(master, base); - final fromDir = - parseHlsVariants(master, Uri.parse('https://cdn.example.test/hls/')); + final fromDir = parseHlsVariants( + master, + Uri.parse('https://cdn.example.test/hls/'), + ); expect(fromDir.single.url, fromMaster.single.url); - expect(fromDir.single.url, - 'https://cdn.example.test/hls/index-s1080p-v1-a1.m3u8'); + expect( + fromDir.single.url, + 'https://cdn.example.test/hls/index-s1080p-v1-a1.m3u8', + ); }); test('a media playlist yields nothing, so the caller keeps its own url', () { diff --git a/test/core/player/local_hls_proxy_test.dart b/test/core/player/local_hls_proxy_test.dart index dcf32f82..3dc0f357 100644 --- a/test/core/player/local_hls_proxy_test.dart +++ b/test/core/player/local_hls_proxy_test.dart @@ -23,6 +23,57 @@ void main() { dio.close(force: true); }); + test( + 'manual HLS quality keeps audio and removes the lower video ladder', + () async { + upstream.respond( + path: '/master.m3u8', + contentType: 'application/vnd.apple.mpegurl', + body: + '#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="a",URI="audio.m3u8"\n' + '#EXT-X-STREAM-INF:BANDWIDTH=12000000,RESOLUTION=1280x720,AUDIO="a"\n720.m3u8\n' + '#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=854x480,AUDIO="a"\n480.m3u8\n', + ); + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/master.m3u8', + headers: const {'Referer': 'https://embed.test/'}, + hlsHeight: 720, + keepOriginHeaders: true, + ); + final body = await _httpGetString(url); + expect(body, contains('720.m3u8')); + expect(body, isNot(contains('480.m3u8'))); + expect(body, contains('audio.m3u8')); + expect( + upstream.lastHeaders!['referer'], + contains('https://embed.test/'), + ); + }, + ); + + test( + 'image-wrapped TS segments use relay but playlists stay on device', + () async { + const segment = + 'https://p16-ad-site-sign-sg.tiktokcdn.com/ad-site-i18n-sg/a.image?x-signature=a%2Bb'; + upstream.respond( + path: '/master.m3u8', + contentType: 'application/vnd.apple.mpegurl', + body: '#EXTM3U\n#EXTINF:10,\n$segment\n#EXT-X-ENDLIST\n', + ); + final url = await proxy.register( + upstreamUrl: '${upstream.origin}/master.m3u8', + headers: const {}, + ); + final body = await _httpGetString(url); + final target = Uri.parse( + body.split('\n').firstWhere((line) => line.startsWith('https://')), + ); + expect(target.path, '/api/contents/media-segment'); + expect(target.queryParameters['url'], segment); + }, + ); + test('does not forward Origin to upstream', () async { upstream.respond( path: '/cdn/manifest/video/x.m3u8', diff --git a/test/core/player/media_segment_relay_test.dart b/test/core/player/media_segment_relay_test.dart new file mode 100644 index 00000000..7795f85f --- /dev/null +++ b/test/core/player/media_segment_relay_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/player/media_segment_relay.dart'; + +void main() { + test('only known wrapped segment URLs use relay and signed query survives', () { + const raw = + 'https://p16-ad-site-sign-sg.tiktokcdn.com/ad-site-i18n-sg/a.image?x-signature=a%2Bb&x-expires=123'; + final rewritten = MediaSegmentRelay.rewrite(Uri.parse(raw))!; + expect(Uri.parse(rewritten).queryParameters['url'], raw); + expect(Uri.parse(rewritten).path, '/api/contents/media-segment'); + expect( + MediaSegmentRelay.rewrite(Uri.parse('https://cdn.test/video.ts')), + isNull, + ); + expect( + MediaSegmentRelay.rewrite( + Uri.parse( + 'https://p16-ad-site-sign-sg.tiktokcdn.com.evil.test/ad-site-i18n-sg/a.image', + ), + ), + isNull, + ); + expect( + MediaSegmentRelay.needsManifestProxy( + 'https://rotated-cdn.test/stream/token/master.m3u8', + ), + isTrue, + ); + expect( + MediaSegmentRelay.needsManifestProxy( + 'https://hgplaycdn.com/stream/master.m3u8', + ), + isTrue, + ); + }); +} diff --git a/test/core/player/playback_readiness_test.dart b/test/core/player/playback_readiness_test.dart index c5bba77c..08449743 100644 --- a/test/core/player/playback_readiness_test.dart +++ b/test/core/player/playback_readiness_test.dart @@ -18,6 +18,68 @@ PlaybackReadiness sample({ ); void main() { + test( + 'mpv codec failure falls back once for remote and downloaded video', + () async { + for (final path in ['https://cdn/video.mp4', '/downloads/video.mp4']) { + var replacements = 0; + final recovered = await initializeWithDecoderFallback( + initialize: () => awaitPlaybackReadiness( + read: () => sample(error: 'Could not open codec.'), + signals: [], + canceled: Completer().future, + ), + canFallback: () => true, + fallback: () async { + replacements++; + }, + ); + expect(recovered, isTrue, reason: path); + expect(replacements, 1); + } + }, + ); + + test('a platform failure propagates without another fallback loop', () async { + var replacements = 0; + await expectLater( + initializeWithDecoderFallback( + initialize: () async => throw StateError('Could not open codec.'), + canFallback: () => true, + fallback: () async { + replacements++; + throw StateError('platform decoder failed'); + }, + ), + throwsStateError, + ); + expect(replacements, 1); + }); + + test( + 'network, HTML and disposal failures do not retry with another engine', + () async { + for (final message in [ + 'Failed to open https://cdn/x', + 'Failed to recognize file format.', + 'Player disposed', + ]) { + var replacements = 0; + await expectLater( + initializeWithDecoderFallback( + initialize: () async => throw StateError(message), + canFallback: () => true, + fallback: () async { + replacements++; + }, + ), + throwsStateError, + ); + expect(replacements, 0); + } + }, + ); + test( 'duration cannot initialize video before decoder dimensions arrive', () async { diff --git a/test/core/player/quality_preference_test.dart b/test/core/player/quality_preference_test.dart index 4f4ce325..df800403 100644 --- a/test/core/player/quality_preference_test.dart +++ b/test/core/player/quality_preference_test.dart @@ -68,8 +68,8 @@ void main() { expect(expand('Server 1', 'https://cdn.test/play', type: 'hls'), isTrue); }); - test('a label with a resolution is the provider\'s own and is kept', () { - expect(expand('1080p', 'https://cdn.test/master.m3u8'), isFalse); + test('a provider label does not hide the actual HLS renditions', () { + expect(expand('1080p', 'https://cdn.test/master.m3u8'), isTrue); }); test('a progressive file has no master to read', () { diff --git a/test/features/detail/retry_policy_test.dart b/test/features/detail/retry_policy_test.dart index 46c2a6e3..f2a2f0e4 100644 --- a/test/features/detail/retry_policy_test.dart +++ b/test/features/detail/retry_policy_test.dart @@ -28,6 +28,7 @@ void main() { 'MediaCodecVideoDecoderException', 'DecoderInitializationException', 'Decoder failed', + 'Bad state: Could not open codec.', ]) { expect(RetryPolicy.isDecoderError(raw), isTrue, reason: raw); } diff --git a/test/features/download/download_request_builder_test.dart b/test/features/download/download_request_builder_test.dart index 9fe13d33..5b8820f1 100644 --- a/test/features/download/download_request_builder_test.dart +++ b/test/features/download/download_request_builder_test.dart @@ -73,6 +73,110 @@ DownloadRequestBuilder _builder({ ); void main() { + test( + 'film and episode embeds are extracted before enqueueing media', + () async { + var sniffed = 0; + final builder = DownloadRequestBuilder( + resolve: _Resolve( + const Success( + MediaResolveEntity( + videoUrl: 'https://luluvdoo.test/e/movie', + headers: {}, + type: 'iframe', + ), + ), + ), + getPages: _Pages(Failure(Exception('unused'))), + resolveEmbed: (media) async { + sniffed++; + expect(media.type, 'iframe'); + return const MediaResolveEntity( + videoUrl: 'https://cdn.test/playlist/token', + headers: {'Referer': 'https://luluvdoo.test/'}, + type: 'hls', + ); + }, + ); + final built = await builder.quietVideo(_title, _ep); + expect(sniffed, 1); + expect(built.request!.kind, DownloadKind.hls); + expect(built.request!.sourceUrl, 'https://cdn.test/playlist/token'); + expect(built.request!.headers['Referer'], 'https://luluvdoo.test/'); + }, + ); + + test( + 'rejected direct and embed mirrors fall through to a verified download', + () async { + final tried = []; + final builder = DownloadRequestBuilder( + resolve: _Resolve(Failure(Exception('unused'))), + getPages: _Pages(Failure(Exception('unused'))), + canDownload: (media) async => !media.videoUrl.contains('expired'), + resolveEmbed: (media) async { + tried.add(media.videoUrl); + return MediaResolveEntity( + videoUrl: media.videoUrl.contains('dead') + ? 'https://cdn/expired.m3u8' + : 'https://cdn/good.m3u8', + headers: const {}, + type: 'hls', + ); + }, + ); + final media = MediaResolveEntity( + videoUrl: 'https://embed/dead', + headers: const {}, + type: 'iframe', + videoSources: [ + _source('1080p', 1080, url: 'https://cdn/expired.m3u8'), + const VideoSourceEntity( + quality: 'alternative', + videoUrl: 'https://embed/good', + isDefault: false, + accessible: true, + type: 'iframe', + ), + ], + ); + final result = await builder.prepareVideo(media); + expect(result.media!.videoUrl, 'https://cdn/good.m3u8'); + expect( + DownloadRequestBuilder.quietPick(result.media!).url, + 'https://cdn/good.m3u8', + ); + expect(tried, ['https://embed/dead', 'https://embed/good']); + }, + ); + + test('a direct mirror is used before sniffing an embed default', () async { + final media = MediaResolveEntity( + videoUrl: 'https://embed.test/page', + headers: const {}, + type: 'iframe', + videoSources: [_source('720p', 720)], + ); + final prepared = await _builder().prepareVideo(media); + expect(prepared.media!.videoUrl, 'https://cdn/720.m3u8'); + }); + + test( + 'extensionless HLS keeps its format when queued for offline playback', + () async { + final built = await _builder( + media: const Success( + MediaResolveEntity( + videoUrl: 'https://og.bakayaro.live/m3u8/token', + headers: {}, + type: 'hls', + ), + ), + ).quietVideo(_title, _ep); + expect(built.request!.kind, DownloadKind.hls); + }, + ); + test('an empty or failed resolve is resolveFailed', () async { final failed = await _builder().quietVideo(_title, _ep); expect(failed.failure, DownloadBuildFailure.resolveFailed); diff --git a/test/features/download/transfer_engine_test.dart b/test/features/download/transfer_engine_test.dart index abca2ac1..ef609ef6 100644 --- a/test/features/download/transfer_engine_test.dart +++ b/test/features/download/transfer_engine_test.dart @@ -105,12 +105,13 @@ audio/prog_index.m3u8 await dir.delete(recursive: true); }); - Future run(DownloadKind kind, String url) => + Future run(DownloadKind kind, String url, {int? height}) => DownloadTransferDataSource(dio: Dio()).run( id: 'x', dirPath: dir.path, kind: kind, sourceUrl: url, + videoHeight: height, headers: const {}, pageUrls: const [], cancel: CancelToken(), @@ -212,6 +213,8 @@ main.mp4 #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="English",LANGUAGE="en",DEFAULT=NO,URI="audio/en.m3u8" #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="Japanese",LANGUAGE="ja",DEFAULT=YES,URI="audio/ja.m3u8" #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="en",URI="subs/en.m3u8" +#EXT-X-STREAM-INF:BANDWIDTH=6000000,RESOLUTION=1920x1080,AUDIO="aud",SUBTITLES="subs" +video/1080.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=1280x720,AUDIO="aud",SUBTITLES="subs" video/720.m3u8 #EXT-X-STREAM-INF:BANDWIDTH=300000,RESOLUTION=640x360,AUDIO="aud",SUBTITLES="subs" @@ -228,7 +231,11 @@ video/360.m3u8 } await r.response.close(); }; - final result = await run(DownloadKind.hls, '$base/master.m3u8'); + final result = await run( + DownloadKind.hls, + '$base/master.m3u8', + height: 720, + ); expect(result.ok, isTrue, reason: result.detail); expect(result.totalUnits, 4); String read(String name) => @@ -246,6 +253,7 @@ video/360.m3u8 expect(index, isNot(contains('SUBTITLES'))); expect(index.trim().split('\n').last, 'video.m3u8'); expect(hits, isNot(contains('/audio/en.m3u8'))); + expect(hits, isNot(contains('/video/1080.m3u8'))); }, ); diff --git a/test/features/mobile_ux_test.dart b/test/features/mobile_ux_test.dart index 86851cc1..2785c5e1 100644 --- a/test/features/mobile_ux_test.dart +++ b/test/features/mobile_ux_test.dart @@ -469,7 +469,8 @@ void main() { await tester.pump(); await tester.tap(find.byType(FilledButton)); await tester.pumpAndSettle(); - expect(selected?.url, 'https://example.test/movie/480/index.m3u8'); + expect(selected?.url, 'https://example.test/movie/master.m3u8'); + expect(selected?.type, 'hls'); expect(selected?.height, 480); }, ); diff --git a/tool/generate_media_hotfix_fixtures.py b/tool/generate_media_hotfix_fixtures.py new file mode 100644 index 00000000..c97f8b15 --- /dev/null +++ b/tool/generate_media_hotfix_fixtures.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Generate synthetic, freely shareable media for android_media_hotfix_test.dart. + +Requires ffmpeg with libx264 and libsvtav1. Usage: + python3 tool/generate_media_hotfix_fixtures.py /tmp/sozo-playback-fixtures + python3 -m http.server 8766 --bind 127.0.0.1 --directory /tmp/sozo-playback-fixtures + adb reverse tcp:8766 tcp:8766 + flutter test integration_test/android_media_hotfix_test.dart -d emulator-5554 +""" +import argparse +from pathlib import Path +import subprocess + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('directory', type=Path) +args = parser.parse_args() +root = args.directory.resolve() +root.mkdir(parents=True, exist_ok=True) +variants = [(480, 854), (720, 1280), (1080, 1920)] +for height, width in variants: + folder = root / str(height) + folder.mkdir(exist_ok=True) + subprocess.run([ + 'ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', + '-f', 'lavfi', '-i', f'testsrc2=size={width}x{height}:rate=12', + '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', + '-t', '8', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '28', + '-pix_fmt', 'yuv420p', '-g', '24', '-c:a', 'aac', + '-movflags', '+faststart', str(folder / 'video.mp4'), + ], check=True) + subprocess.run([ + 'ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', + '-i', str(folder / 'video.mp4'), '-c', 'copy', '-hls_time', '2', + '-hls_playlist_type', 'vod', '-hls_segment_filename', + str(folder / 'seg%d.ts'), str(folder / 'index.m3u8'), + ], check=True) +subprocess.run([ + 'ffmpeg', '-hide_banner', '-loglevel', 'error', '-y', + '-f', 'lavfi', '-i', 'testsrc2=size=1920x1080:rate=12', + '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', + '-t', '5', '-c:v', 'libsvtav1', '-preset', '12', '-crf', '45', + '-pix_fmt', 'yuv420p10le', '-c:a', 'aac', '-movflags', '+faststart', + str(root / 'av1-10bit.mp4'), +], check=True) +(root / 'master.m3u8').write_text('#EXTM3U\n' + ''.join( + f'#EXT-X-STREAM-INF:BANDWIDTH={height * 2000},RESOLUTION={width}x{height}\n' + f'{height}/index.m3u8\n' for height, width in variants +)) +(root / 'blocked.html').write_text('Upstream challenge') +(root / 'nested-player.html').write_text('') +(root / 'inner-player.html').write_text('') +print(f'Fixtures ready: {root}')