diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-22 23:30:05 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-22 23:30:05 +0300 |
| commit | 8d450ee93f095cfd2c5374b181d34ead9b49d502 (patch) | |
| tree | 40f303e291df57e73d0ae5e87bcfac0afe0c80c4 /player-android/lib/screens | |
| parent | 9cd7ccc5d1150bf841a3961df9cb1e8a5d25abfe (diff) | |
Pass session cookie to ExoPlayer/just_audio for authenticated streaming
The audio/video players spawn a localhost proxy (just_audio) or call
ExoPlayer directly (video_player) using their own HTTP stack, which does
not share Dio's cookie jar. Without the session cookie those requests
hit the stream endpoint anonymously and fail with 401.
Expose the Dio CookieJar via a Riverpod provider (cookieJarProvider) and
attach a Cookie header (alongside the existing Authorization: Bearer) to
both AudioSource.uri and VideoPlayerController.networkUrl.
Also enable android:usesCleartextTraffic="true" on the Application —
just_audio's headers-injection proxy listens on 127.0.0.1 and Android
28+ blocks cleartext to it without the explicit opt-in.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android/lib/screens')
| -rw-r--r-- | player-android/lib/screens/audio_player_screen.dart | 26 | ||||
| -rw-r--r-- | player-android/lib/screens/video_player_screen.dart | 11 |
2 files changed, 29 insertions, 8 deletions
diff --git a/player-android/lib/screens/audio_player_screen.dart b/player-android/lib/screens/audio_player_screen.dart index 9e28062..4f05abf 100644 --- a/player-android/lib/screens/audio_player_screen.dart +++ b/player-android/lib/screens/audio_player_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:cookie_jar/cookie_jar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart'; @@ -131,11 +132,12 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { final player = handler.player; final client = ref.read(apiClientProvider); final storage = ref.read(tokenStorageProvider); + final cookieJar = ref.read(cookieJarProvider); final mediaIdInt = int.tryParse(widget.mediaId) ?? 0; final url = widget.mediaUrl ?? client.streamUrl(mediaIdInt); - // Step 1–2: build auth headers. - final headers = await _buildAuthHeaders(storage); + // Step 1–2: build auth headers (Bearer + session cookie). + final headers = await _buildAuthHeaders(storage, cookieJar, Uri.parse(url)); if (!mounted) return; // Step 3: load the authenticated source; show error UI on failure. @@ -162,14 +164,26 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { _startProgressTicker(mediaIdInt, client, player, queue); } - /// Reads the bearer token and returns the `Authorization` header map. + /// Builds the headers map for an authenticated stream request. /// - /// Returns an empty map when no token is stored so the source can still be - /// loaded (e.g., public streams or during tests). - Future<Map<String, String>> _buildAuthHeaders(TokenStorage storage) async { + /// just_audio runs a localhost proxy that forwards these headers to + /// ExoPlayer's underlying HTTP request, which is how we authenticate against + /// the session-cookie-protected `/api/v1/media/{id}/stream` endpoint without + /// sharing Dio's HTTP stack. Both Bearer (for API-token auth) and Cookie + /// (for session auth) are attached so either auth scheme works. + Future<Map<String, String>> _buildAuthHeaders( + TokenStorage storage, + CookieJar jar, + Uri url, + ) async { final token = await storage.readToken(); + final cookies = await jar.loadForRequest(url); + final cookieHeader = cookies + .map((c) => '${c.name}=${c.value}') + .join('; '); return <String, String>{ if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', + if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader, }; } diff --git a/player-android/lib/screens/video_player_screen.dart b/player-android/lib/screens/video_player_screen.dart index 763168d..2172a12 100644 --- a/player-android/lib/screens/video_player_screen.dart +++ b/player-android/lib/screens/video_player_screen.dart @@ -116,19 +116,26 @@ class _VideoPlayerScreenState extends ConsumerState<VideoPlayerScreen> { final client = ref.read(apiClientProvider); final storage = ref.read(tokenStorageProvider); + final cookieJar = ref.read(cookieJarProvider); final mediaIdInt = int.tryParse(widget.mediaId) ?? 0; // Step 1: resolve the stream URL — prefer the route-extra URL so the // calling screen can forward a pre-computed URL; fall back to streamUrl. final url = widget.mediaUrl ?? client.streamUrl(mediaIdInt); - // Step 2: read the bearer token so the native player can authenticate - // without routing bytes through Dart (performance and correctness). + // Step 2: read the auth artefacts so the native player can authenticate + // without routing bytes through Dart. Both Bearer (API-token auth) and + // Cookie (session auth) headers are attached because ExoPlayer has its + // own HTTP stack and does not share Dio's cookie jar. final token = await storage.readToken(); + final cookies = await cookieJar.loadForRequest(Uri.parse(url)); if (!mounted) return; + final cookieHeader = + cookies.map((c) => '${c.name}=${c.value}').join('; '); final headers = <String, String>{ if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', + if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader, }; // Step 3: create and initialise the VideoPlayerController. |
