summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--player-android/android/app/src/main/AndroidManifest.xml3
-rw-r--r--player-android/lib/api/dio_client.dart39
-rw-r--r--player-android/lib/providers/api_client_provider.dart21
-rw-r--r--player-android/lib/screens/audio_player_screen.dart26
-rw-r--r--player-android/lib/screens/video_player_screen.dart11
5 files changed, 72 insertions, 28 deletions
diff --git a/player-android/android/app/src/main/AndroidManifest.xml b/player-android/android/app/src/main/AndroidManifest.xml
index 81db32c..00d1b91 100644
--- a/player-android/android/app/src/main/AndroidManifest.xml
+++ b/player-android/android/app/src/main/AndroidManifest.xml
@@ -12,7 +12,8 @@
<application
android:label="Player"
android:name="${applicationName}"
- android:icon="@drawable/ic_launcher">
+ android:icon="@drawable/ic_launcher"
+ android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
diff --git a/player-android/lib/api/dio_client.dart b/player-android/lib/api/dio_client.dart
index c988cf0..7a46885 100644
--- a/player-android/lib/api/dio_client.dart
+++ b/player-android/lib/api/dio_client.dart
@@ -113,32 +113,48 @@ class _UnauthorizedInterceptor extends Interceptor {
/// Callers own the returned [Dio] and may add further interceptors on top.
/// Separating construction from usage (SRP) keeps this class testable.
class DioClient {
- DioClient({
+ factory DioClient({
required Uri baseUrl,
required TokenStorage storage,
required GlobalKey<NavigatorState> navigatorKey,
String loginRoute = '/login',
BaseOptions? baseOptions,
- }) : _dio = _buildDio(
- baseUrl: baseUrl,
- storage: storage,
- navigatorKey: navigatorKey,
- loginRoute: loginRoute,
- baseOptions: baseOptions,
- );
+ }) {
+ final jar = CookieJar();
+ final dio = _buildDio(
+ baseUrl: baseUrl,
+ storage: storage,
+ navigatorKey: navigatorKey,
+ loginRoute: loginRoute,
+ baseOptions: baseOptions,
+ cookieJar: jar,
+ );
+ return DioClient._(dio: dio, cookieJar: jar);
+ }
+
+ DioClient._({required Dio dio, required CookieJar cookieJar})
+ : _dio = dio,
+ _cookieJar = cookieJar;
final Dio _dio;
+ final CookieJar _cookieJar;
/// Exposes the underlying [Dio] so that [PlayerApiClient] can issue typed
/// requests without re-implementing the interceptor plumbing.
Dio get dio => _dio;
+ /// Exposes the cookie jar so consumers that bypass Dio (e.g. ExoPlayer via
+ /// just_audio, video_player, CachedNetworkImage) can still authenticate
+ /// against the session-cookie-protected media endpoints.
+ CookieJar get cookieJar => _cookieJar;
+
static Dio _buildDio({
required Uri baseUrl,
required TokenStorage storage,
required GlobalKey<NavigatorState> navigatorKey,
required String loginRoute,
BaseOptions? baseOptions,
+ required CookieJar cookieJar,
}) {
final options = (baseOptions ?? BaseOptions()).copyWith(
baseUrl: baseUrl.toString(),
@@ -147,13 +163,6 @@ class DioClient {
responseType: ResponseType.json,
);
- // The server's /api/v1/auth/login sets an HttpOnly Set-Cookie (session=...).
- // Browsers persist this automatically; on mobile we attach a CookieJar so
- // Dio replays the cookie on subsequent requests. Without this, every call
- // after login returns 401 because Dio discards cookies by default.
- // In-memory is sufficient: logout clears it, and we persist the bearer
- // token (for API-token auth) separately via flutter_secure_storage.
- final cookieJar = CookieJar();
return Dio(options)
..interceptors.addAll([
// Cookie manager runs first so the session cookie is replayed before
diff --git a/player-android/lib/providers/api_client_provider.dart b/player-android/lib/providers/api_client_provider.dart
index 9a3ca8d..27bd62f 100644
--- a/player-android/lib/providers/api_client_provider.dart
+++ b/player-android/lib/providers/api_client_provider.dart
@@ -1,3 +1,4 @@
+import 'package:cookie_jar/cookie_jar.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/dio_client.dart';
@@ -36,10 +37,13 @@ final tokenStorageProvider = Provider<TokenStorage>((ref) {
/// Depends on [tokenStorageProvider] and [navigatorKey] (both singletons) so
/// the same [Dio] instance is reused for every call site — avoiding redundant
/// interceptor stacks.
-final apiClientProvider = Provider<PlayerApiClient>((ref) {
+// Single shared DioClient instance: cached as a Riverpod Provider so both the
+// API client and the cookie-jar provider observe the same cookie store. The
+// API client uses Dio; ExoPlayer/video_player/CachedNetworkImage bypass Dio
+// and need the cookie jar to attach the session cookie manually.
+final _dioClientProvider = Provider<DioClient>((ref) {
final storage = ref.watch(tokenStorageProvider);
-
- final dioClient = DioClient(
+ return DioClient(
baseUrl: Uri.parse(kPlayerBaseUrl),
storage: storage,
// Share the navigator key with go_router so 401 redirects go through the
@@ -47,10 +51,19 @@ final apiClientProvider = Provider<PlayerApiClient>((ref) {
navigatorKey: navigatorKey,
loginRoute: '/login',
);
+});
+final apiClientProvider = Provider<PlayerApiClient>((ref) {
// Use DioPlayerApiClient — the concrete implementation that maps every
// PlayerApiClient method to a real HTTP call via Dio. The base class now
// acts as the public interface (dependency inversion); callers depend on
// PlayerApiClient, not on this concrete class.
- return DioPlayerApiClient(dio: dioClient.dio);
+ return DioPlayerApiClient(dio: ref.watch(_dioClientProvider).dio);
+});
+
+/// Provides the same [CookieJar] backing [apiClientProvider]'s Dio stack so
+/// non-Dio HTTP clients (ExoPlayer, video_player, CachedNetworkImage) can
+/// authenticate streaming/thumbnail requests using the same session cookie.
+final cookieJarProvider = Provider<CookieJar>((ref) {
+ return ref.watch(_dioClientProvider).cookieJar;
});
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.