summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-22 09:17:31 +0300
committerPaul Buetow <paul@buetow.org>2026-05-22 09:17:31 +0300
commit1824175276fcd29d4b6d4c14277c62e73247b7e2 (patch)
treeb32c9867f24d4ca34bb5a902adb9fb508ddc00d1
parent1b66b5f573c8de883947d712057ff85f5513e8ae (diff)
Implement public ShareViewerScreen with Android deep-link intent-filter (9b)
- New ShareViewerScreen (/share/:token): unauthenticated share-viewer that fetches share metadata via publicApiClientProvider, renders filename, type, duration, thumbnail, and a Play button routing to the video/audio player. - New publicApiClientProvider: bare Dio client (no auth interceptors) for the public share endpoint; shares kPlayerBaseUrl with the authenticated client. - AndroidManifest: http + https deep-link intent-filters for /share/.* so Android routes share URLs directly into the app (App Links / autoVerify). - router.dart: /share/:token bypasses the authentication redirect; guard uses AppRoutes.shareViewerPrefix constant instead of a raw '/share/' string (DIP). - app_routes.dart: shareViewer route constant, shareViewerPrefix, shareViewerPath helper. - error_mappers.dart: shareViewerErrorMessage — 404 invalid/revoked, 410 expired. - player_api_client.dart: baseUrl getter encapsulates rawDio.options.baseUrl so screens never access transport internals directly (ISP, DIP). - Review fixes: OCP icon map in _FallbackThumbnail, LSP explicit baseUrl overrides in test fakes, DIP shareViewerPrefix constant. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--player-android/android/app/src/main/AndroidManifest.xml30
-rw-r--r--player-android/lib/api/player_api_client.dart9
-rw-r--r--player-android/lib/app_routes.dart21
-rw-r--r--player-android/lib/providers/api_client_provider.dart18
-rw-r--r--player-android/lib/providers/public_api_client_provider.dart30
-rw-r--r--player-android/lib/router.dart31
-rw-r--r--player-android/lib/screens/share_viewer_screen.dart429
-rw-r--r--player-android/lib/utils/error_mappers.dart26
-rw-r--r--player-android/test/screens/share_viewer_screen_test.dart443
9 files changed, 1027 insertions, 10 deletions
diff --git a/player-android/android/app/src/main/AndroidManifest.xml b/player-android/android/app/src/main/AndroidManifest.xml
index 602051c..3276ad7 100644
--- a/player-android/android/app/src/main/AndroidManifest.xml
+++ b/player-android/android/app/src/main/AndroidManifest.xml
@@ -29,6 +29,36 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
+ <!-- Deep-link intent-filter for public share URLs.
+ When a user opens a link of the form
+ https://<PLAYER_HOST>/share/<token>
+ Android routes the intent to this activity and go_router
+ navigates to the ShareViewerScreen (/share/:token).
+ Replace PLAYER_HOST with the real server hostname (e.g.
+ player.example.com) for production deployments; the wildcard
+ host below accepts any host for development flexibility.
+ android:autoVerify="true" enables App Links (verified deep
+ links) once an assetlinks.json file is served at the host;
+ without verification the OS shows the disambiguation dialog.
+ Both http and https schemes are declared so links work with
+ self-signed or non-TLS development servers as well as
+ production HTTPS deployments. -->
+ <intent-filter android:autoVerify="true">
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="https"
+ android:host="PLAYER_HOST"
+ android:pathPattern="/share/.*" />
+ </intent-filter>
+ <intent-filter android:autoVerify="true">
+ <action android:name="android.intent.action.VIEW" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <category android:name="android.intent.category.BROWSABLE" />
+ <data android:scheme="http"
+ android:host="PLAYER_HOST"
+ android:pathPattern="/share/.*" />
+ </intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart
index 0094a90..ffb3457 100644
--- a/player-android/lib/api/player_api_client.dart
+++ b/player-android/lib/api/player_api_client.dart
@@ -177,6 +177,15 @@ class PlayerApiClient implements ProgressSyncClient {
/// hard-code URL segments (Dependency Inversion Principle).
String shareUrl(String token) => '${rawDio.options.baseUrl}/s/$token';
+ /// Returns the base URL of the underlying Dio instance without a trailing slash.
+ ///
+ /// Exposed so screens can construct absolute URLs from server-relative paths
+ /// (e.g., `/s/abc123/stream`) without reaching into [rawDio] directly.
+ /// Encapsulating this lookup here prevents the Dio transport detail from
+ /// leaking into the UI layer (Interface Segregation, Dependency Inversion).
+ String get baseUrl =>
+ rawDio.options.baseUrl.replaceAll(RegExp(r'/$'), '');
+
Future<void> regenerateThumbnail(int mediaId) => throw UnimplementedError();
Future<bool> toggleFavorite(int mediaId) => throw UnimplementedError();
diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart
index c9207f8..c1f350e 100644
--- a/player-android/lib/app_routes.dart
+++ b/player-android/lib/app_routes.dart
@@ -8,6 +8,14 @@ abstract final class AppRoutes {
static const home = '/home';
static const mediaDetail = '/media/:id';
static const share = '/share';
+
+ /// Public share-viewer route — no authentication required.
+ ///
+ /// The ':token' segment is the opaque share token issued by the server.
+ /// This route is intentionally outside the authenticated route set so
+ /// anyone with a share link can view the shared media without logging in.
+ static const shareViewer = '/share/:token';
+
static const settings = '/settings';
/// First-run setup route shown when no admin account exists yet.
@@ -36,6 +44,19 @@ abstract final class AppRoutes {
/// The ':mediaId' segment identifies the media item whose note is edited.
static const notes = '/notes/:mediaId';
+ /// URL prefix used in the router redirect guard to identify share-viewer URLs.
+ ///
+ /// The go_router pattern [shareViewer] is a template string with a `:token`
+ /// placeholder and cannot be used directly as a prefix check. This constant
+ /// encapsulates the literal prefix so [router.dart]'s redirect logic can
+ /// reference a named constant rather than embedding a raw string literal
+ /// (Dependency Inversion — high-level routing policy depends on this abstraction,
+ /// not on a hardcoded '/share/' string).
+ static const shareViewerPrefix = '/share/';
+
+ /// Returns the concrete path for the share-viewer page of a given [token].
+ static String shareViewerPath(String token) => '/share/$token';
+
/// Returns the concrete path for a media-detail page given a numeric [id].
static String mediaDetailPath(int id) => '/media/$id';
diff --git a/player-android/lib/providers/api_client_provider.dart b/player-android/lib/providers/api_client_provider.dart
index 128b89d..9a3ca8d 100644
--- a/player-android/lib/providers/api_client_provider.dart
+++ b/player-android/lib/providers/api_client_provider.dart
@@ -5,12 +5,18 @@ import '../api/dio_player_api_client.dart';
import '../api/player_api_client.dart';
import '../navigation_key.dart';
-/// Base URL for the player-server API.
+/// Base URL for the player-server API, resolved at compile time via
+/// the PLAYER_BASE_URL environment variable (or the default below).
///
-/// In production this is injected from the environment or a config file.
-/// The default points to a local dev instance so the app is runnable
-/// without extra configuration.
-const _kBaseUrl = String.fromEnvironment(
+/// Declared as a package-level identifier (no underscore) so it can be
+/// shared by [publicApiClientProvider] in [public_api_client_provider.dart].
+/// The value is set once at compile time and never changes at runtime,
+/// making it safe to share across providers.
+///
+/// In production this is injected via `--dart-define=PLAYER_BASE_URL=...`.
+/// The default points to the Android emulator host loopback address so the
+/// app is runnable out-of-the-box without extra configuration.
+const kPlayerBaseUrl = String.fromEnvironment(
'PLAYER_BASE_URL',
defaultValue: 'http://10.0.2.2:8080',
);
@@ -34,7 +40,7 @@ final apiClientProvider = Provider<PlayerApiClient>((ref) {
final storage = ref.watch(tokenStorageProvider);
final dioClient = DioClient(
- baseUrl: Uri.parse(_kBaseUrl),
+ baseUrl: Uri.parse(kPlayerBaseUrl),
storage: storage,
// Share the navigator key with go_router so 401 redirects go through the
// correct router instance rather than the raw Navigator.
diff --git a/player-android/lib/providers/public_api_client_provider.dart b/player-android/lib/providers/public_api_client_provider.dart
new file mode 100644
index 0000000..a27662b
--- /dev/null
+++ b/player-android/lib/providers/public_api_client_provider.dart
@@ -0,0 +1,30 @@
+import 'package:dio/dio.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../api/dio_player_api_client.dart';
+import '../api/player_api_client.dart';
+import 'api_client_provider.dart' show kPlayerBaseUrl;
+
+/// Provides an unauthenticated [PlayerApiClient] for public endpoints.
+///
+/// Unlike [apiClientProvider], this client has no bearer-token interceptor
+/// and no 401 → login redirect interceptor. It is designed exclusively for
+/// the share-viewer feature, where the share token is part of the URL path
+/// (not an Authorization header) and no session is required.
+///
+/// Using a dedicated provider keeps the authenticated and public clients
+/// cleanly separated (Single Responsibility, Separation of Concerns) and
+/// avoids accidentally attaching a session to public requests.
+final publicApiClientProvider = Provider<PlayerApiClient>((ref) {
+ // Minimal Dio instance: JSON content-type, same base URL as the auth client,
+ // but without any auth or redirect interceptors.
+ final dio = Dio(
+ BaseOptions(
+ baseUrl: kPlayerBaseUrl,
+ contentType: 'application/json',
+ responseType: ResponseType.json,
+ ),
+ );
+
+ return DioPlayerApiClient(dio: dio);
+});
diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart
index 36cdd50..c838a8b 100644
--- a/player-android/lib/router.dart
+++ b/player-android/lib/router.dart
@@ -17,6 +17,7 @@ import 'screens/podcast_episodes_screen.dart';
import 'screens/podcast_list_screen.dart';
import 'screens/settings_screen.dart';
import 'screens/share_screen.dart';
+import 'screens/share_viewer_screen.dart';
import 'screens/my_shares_screen.dart';
import 'screens/notes_editor_screen.dart';
import 'screens/folder_browser_screen.dart';
@@ -64,16 +65,26 @@ final routerProvider = Provider<GoRouter>((ref) {
final isLoginRoute = location == AppRoutes.login;
// Bootstrap is a public route (user is unauthenticated by definition).
final isBootstrapRoute = location == AppRoutes.bootstrap;
+ // Share-viewer routes (/share/:token) are public — no session required.
+ // The token is embedded in the URL path; authentication is irrelevant.
+ // Uses [AppRoutes.shareViewerPrefix] rather than a raw string literal so
+ // a rename of the share-viewer path is reflected here automatically (DIP).
+ final isShareViewerRoute =
+ location.startsWith(AppRoutes.shareViewerPrefix);
if (auth.isAuthenticated && (isLoginRoute || isBootstrapRoute)) {
// Prevent already-authenticated users from viewing auth/setup screens.
return AppRoutes.home;
}
- if (auth.isUnauthenticated && !isLoginRoute && !isBootstrapRoute) {
- // Unauthenticated: any route other than /login and /bootstrap is
- // protected. This covers /home, /media/:id, /share, /settings, and
- // any future authenticated routes added to the route table.
+ if (auth.isUnauthenticated &&
+ !isLoginRoute &&
+ !isBootstrapRoute &&
+ !isShareViewerRoute) {
+ // Unauthenticated: any route other than /login, /bootstrap, and
+ // /share/:token is protected. This covers /home, /media/:id,
+ // /share (list), /settings, and any future authenticated routes added
+ // to the route table.
//
// Determine whether this is first-run (no users exist yet) or a normal
// returning-user scenario. firstRunProvider returns true when the
@@ -139,6 +150,18 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) => const ShareScreen(),
),
GoRoute(
+ // Public share-viewer — no authentication required.
+ // The ':token' path parameter is the opaque share token from the URL.
+ // This route is intentionally separate from /share (the authenticated
+ // share-management screen) and must remain before the redirect logic
+ // guards it in any future refactor.
+ path: AppRoutes.shareViewer,
+ builder: (context, state) {
+ final token = state.pathParameters['token']!;
+ return ShareViewerScreen(token: token);
+ },
+ ),
+ GoRoute(
path: AppRoutes.settings,
builder: (context, state) => const SettingsScreen(),
),
diff --git a/player-android/lib/screens/share_viewer_screen.dart b/player-android/lib/screens/share_viewer_screen.dart
new file mode 100644
index 0000000..6cb5070
--- /dev/null
+++ b/player-android/lib/screens/share_viewer_screen.dart
@@ -0,0 +1,429 @@
+import 'dart:convert';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../app_routes.dart';
+import '../providers/public_api_client_provider.dart';
+import '../utils/duration_formatter.dart';
+import '../utils/error_mappers.dart';
+
+// ---------------------------------------------------------------------------
+// Share page metadata model
+// ---------------------------------------------------------------------------
+
+/// Parsed metadata from the GET /s/{token} JSON response.
+///
+/// Keeps the screen layer free from raw map access: the data is extracted once
+/// here and then consumed via typed fields (Separation of Concerns).
+class SharePageMetadata {
+ const SharePageMetadata({
+ required this.fileName,
+ required this.type,
+ required this.duration,
+ required this.hasThumb,
+ required this.streamUrl,
+ required this.thumbUrl,
+ required this.downloadUrl,
+ });
+
+ final String fileName;
+ final String type;
+
+ /// Duration in seconds; null when the server omits the field.
+ final double? duration;
+
+ final bool hasThumb;
+
+ /// Relative path for the stream endpoint (e.g. "/s/abc123/stream").
+ final String streamUrl;
+
+ /// Relative path for the thumbnail (e.g. "/s/abc123/thumbnail").
+ final String thumbUrl;
+
+ /// Relative path for downloading the original file.
+ final String downloadUrl;
+
+ /// Parses a [SharePageMetadata] from the raw JSON string returned by
+ /// [PlayerApiClient.getSharedMediaPage].
+ ///
+ /// Missing fields are replaced with safe defaults so the screen always has
+ /// something to display rather than throwing on an unexpected server response.
+ factory SharePageMetadata.fromJson(String jsonBody) {
+ final map = jsonDecode(jsonBody) as Map<String, dynamic>;
+ final media = map['media'] as Map<String, dynamic>? ?? {};
+
+ return SharePageMetadata(
+ fileName: (media['file_name'] as String?) ?? 'Unknown file',
+ type: (media['type'] as String?) ?? 'video',
+ duration: (media['duration'] as num?)?.toDouble(),
+ hasThumb: (map['has_thumb'] as bool?) ?? false,
+ streamUrl: (map['stream_url'] as String?) ?? '',
+ thumbUrl: (map['thumb_url'] as String?) ?? '',
+ downloadUrl: (map['download_url'] as String?) ?? '',
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ShareViewerScreen
+// ---------------------------------------------------------------------------
+
+/// Public share-viewer screen — no authentication required.
+///
+/// Accepts a [token] from the go_router path parameter (/share/:token).
+/// Calls the unauthenticated [publicApiClientProvider] to fetch share metadata
+/// and renders the file name, type, duration, and thumbnail. A "Play" button
+/// navigates to the appropriate video or audio player with the stream URL so
+/// the viewer can watch or listen without a user account.
+///
+/// Design notes:
+/// - [ConsumerStatefulWidget] is used so local state (loading, error, data)
+/// is managed without extra Riverpod providers for transient UI state, and
+/// so [mounted] guards are available on all async continuations.
+/// - No [Dio] import: all HTTP calls go through [publicApiClientProvider],
+/// keeping the screen layer decoupled from the HTTP transport (DIP).
+/// - The screen does not import any authenticated provider, ensuring it cannot
+/// accidentally attach a session to a public request.
+/// - Progress updates sent by the player screen will fail silently for
+/// public shares (the progress endpoint requires auth) — this is acceptable
+/// because progress tracking is a per-user authenticated feature.
+class ShareViewerScreen extends ConsumerStatefulWidget {
+ const ShareViewerScreen({super.key, required this.token});
+
+ /// The opaque share token extracted from the URL path by go_router.
+ final String token;
+
+ @override
+ ConsumerState<ShareViewerScreen> createState() => _ShareViewerScreenState();
+}
+
+class _ShareViewerScreenState extends ConsumerState<ShareViewerScreen> {
+ // Null during the initial load; non-null after a successful fetch.
+ SharePageMetadata? _page;
+
+ // Non-null when the last fetch attempt failed.
+ String? _error;
+
+ // True while the initial or retry load is in flight.
+ bool _isLoading = false;
+
+ @override
+ void initState() {
+ super.initState();
+ // Defer first load until after the first frame so any provider overrides
+ // applied in tests are in place before [ref] is accessed.
+ WidgetsBinding.instance.addPostFrameCallback((_) => _load());
+ }
+
+ // ---------------------------------------------------------------------------
+ // Data loading
+ // ---------------------------------------------------------------------------
+
+ /// Fetches share metadata from the server using the unauthenticated client.
+ ///
+ /// Uses [publicApiClientProvider] so no bearer token is attached.
+ /// Errors are mapped to human-readable strings by [shareViewerErrorMessage].
+ Future<void> _load() async {
+ if (!mounted) return;
+ setState(() {
+ _isLoading = true;
+ _error = null;
+ });
+
+ try {
+ final client = ref.read(publicApiClientProvider);
+ final json = await client.getSharedMediaPage(widget.token);
+ final page = SharePageMetadata.fromJson(json);
+ if (!mounted) return;
+ setState(() {
+ _page = page;
+ _isLoading = false;
+ });
+ } catch (e) {
+ if (!mounted) return;
+ setState(() {
+ _error = shareViewerErrorMessage(e);
+ _isLoading = false;
+ });
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Play action
+ // ---------------------------------------------------------------------------
+
+ /// Navigates to the appropriate player screen with the absolute stream URL.
+ ///
+ /// Builds the absolute URL from [PlayerApiClient.baseUrl] and the relative
+ /// [SharePageMetadata.streamUrl] path so the player screen receives a fully
+ /// qualified URL it can pass directly to ExoPlayer / just_audio.
+ ///
+ /// The player screen's [mediaId] is set to '0' as a placeholder because
+ /// progress tracking (which requires auth) is not available for public shares;
+ /// failed progress-update calls in the player are already fire-and-forget and
+ /// do not affect playback.
+ void _play() {
+ if (_page == null) return;
+
+ final client = ref.read(publicApiClientProvider);
+ final absoluteStreamUrl = '${client.baseUrl}${_page!.streamUrl}';
+
+ // Navigate to the audio or video player based on the media type.
+ // The stream URL is passed as a route extra so the player uses it directly
+ // without deriving it from a media ID (Dependency Inversion).
+ final playerPath = AppRoutes.playerPathForType(_page!.type, '0');
+ context.go(playerPath, extra: absoluteStreamUrl);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Build
+ // ---------------------------------------------------------------------------
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('Shared Media')),
+ body: _buildBody(context),
+ );
+ }
+
+ /// Returns the appropriate body widget for the current state:
+ /// - Full-screen spinner while the first load is in flight.
+ /// - Error view with retry button when the fetch failed.
+ /// - Metadata card with Play button when data is ready.
+ Widget _buildBody(BuildContext context) {
+ if (_isLoading && _page == null) {
+ return const Center(
+ key: Key('share_viewer_loading'),
+ child: CircularProgressIndicator(),
+ );
+ }
+
+ if (_error != null) {
+ return _ErrorView(message: _error!, onRetry: _load);
+ }
+
+ if (_page == null) {
+ // Defensive guard: should not be reachable under normal flow.
+ return const SizedBox.shrink();
+ }
+
+ return _MetadataView(page: _page!, onPlay: _play);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Sub-widgets
+// ---------------------------------------------------------------------------
+
+/// Displays the share metadata and the Play button.
+///
+/// Extracted into its own stateless widget so the parent state class stays
+/// focused on data-loading concerns and the UI is independently testable
+/// (Single Responsibility Principle).
+class _MetadataView extends StatelessWidget {
+ const _MetadataView({required this.page, required this.onPlay});
+
+ final SharePageMetadata page;
+ final VoidCallback onPlay;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+
+ return SingleChildScrollView(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // Thumbnail or media-type icon placeholder.
+ _ThumbnailWidget(page: page),
+ const SizedBox(height: 16),
+
+ // File name — primary text element of the card.
+ Text(
+ page.fileName,
+ key: const Key('share_viewer_filename'),
+ style: theme.textTheme.titleLarge,
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 8),
+
+ // Type and duration shown as a secondary metadata row.
+ _MetadataRow(page: page),
+ const SizedBox(height: 24),
+
+ // Play button — navigates to the appropriate player screen.
+ FilledButton.icon(
+ key: const Key('share_viewer_play_button'),
+ onPressed: onPlay,
+ icon: const Icon(Icons.play_arrow),
+ label: const Text('Play'),
+ style: FilledButton.styleFrom(
+ padding: const EdgeInsets.symmetric(vertical: 14),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+/// Shows the thumbnail image when available, or a media-type icon placeholder.
+///
+/// Delegates to [_ThumbnailImage] when [SharePageMetadata.hasThumb] is true,
+/// or to [_FallbackThumbnail] when there is no thumbnail (Open-Closed: adding
+/// a new media type only requires extending [_FallbackThumbnail]).
+class _ThumbnailWidget extends ConsumerWidget {
+ const _ThumbnailWidget({required this.page});
+
+ final SharePageMetadata page;
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ if (!page.hasThumb || page.thumbUrl.isEmpty) {
+ return _FallbackThumbnail(type: page.type);
+ }
+
+ final client = ref.read(publicApiClientProvider);
+ final absoluteThumbUrl = '${client.baseUrl}${page.thumbUrl}';
+
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: AspectRatio(
+ aspectRatio: 16 / 9,
+ child: Image.network(
+ absoluteThumbUrl,
+ key: const Key('share_viewer_thumbnail'),
+ fit: BoxFit.cover,
+ // Fall back to the type icon if the image fails to load (e.g. network
+ // error) so the viewer always sees something meaningful.
+ errorBuilder: (_, __, ___) => _FallbackThumbnail(type: page.type),
+ ),
+ ),
+ );
+ }
+}
+
+/// Icon placeholder shown when no thumbnail is available or fails to load.
+///
+/// The icon is chosen by [type] so audio shares show a music note while video
+/// shares show a movie icon, giving the viewer a visual hint about the content.
+///
+/// The icon lookup uses a map rather than a binary conditional so that new
+/// media types can be added by extending [_typeIcons] alone (Open-Closed
+/// Principle) — no if/else chain to update.
+class _FallbackThumbnail extends StatelessWidget {
+ const _FallbackThumbnail({required this.type});
+
+ final String type;
+
+ /// Maps media type strings to their representative Material icons.
+ ///
+ /// Unknown types fall back to [Icons.movie] via the null-coalescing lookup
+ /// in [build], so new server-side types degrade gracefully without crashes.
+ static const _typeIcons = {
+ 'audio': Icons.audio_file,
+ 'video': Icons.movie,
+ };
+
+ @override
+ Widget build(BuildContext context) {
+ final icon = _typeIcons[type] ?? Icons.movie;
+ return AspectRatio(
+ aspectRatio: 16 / 9,
+ child: Container(
+ key: const Key('share_viewer_thumbnail_placeholder'),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(8),
+ ),
+ child: Icon(
+ icon,
+ size: 72,
+ color: Theme.of(context).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ );
+ }
+}
+
+/// One-line row showing the media type (capitalised) and formatted duration.
+///
+/// Placed below the filename so the viewer can see at a glance what kind of
+/// media the link points to and how long it is before tapping Play.
+class _MetadataRow extends StatelessWidget {
+ const _MetadataRow({required this.page});
+
+ final SharePageMetadata page;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final typeLabel = _capitalise(page.type);
+ final durationLabel =
+ page.duration != null ? formatDuration(page.duration!) : null;
+
+ final parts = [typeLabel, if (durationLabel != null) durationLabel];
+
+ return Text(
+ parts.join(' · '),
+ key: const Key('share_viewer_metadata'),
+ style: theme.textTheme.bodyMedium?.copyWith(
+ color: theme.colorScheme.onSurfaceVariant,
+ ),
+ textAlign: TextAlign.center,
+ );
+ }
+
+ /// Returns [s] with its first character uppercased.
+ static String _capitalise(String s) =>
+ s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}';
+}
+
+/// Full-screen error view with a retry button.
+///
+/// Shown when [getSharedMediaPage] throws — e.g. 404 (invalid token),
+/// 410 (expired link), or a network failure. The [message] comes from
+/// [shareViewerErrorMessage], which maps exceptions to human-readable strings.
+class _ErrorView extends StatelessWidget {
+ const _ErrorView({required this.message, required this.onRetry});
+
+ final String message;
+ final VoidCallback onRetry;
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.link_off,
+ size: 56,
+ color: Theme.of(context).colorScheme.error,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ message,
+ key: const Key('share_viewer_error'),
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.bodyLarge,
+ ),
+ const SizedBox(height: 24),
+ ElevatedButton.icon(
+ key: const Key('share_viewer_retry'),
+ onPressed: onRetry,
+ icon: const Icon(Icons.refresh),
+ label: const Text('Retry'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart
index b0d3511..8f5c78b 100644
--- a/player-android/lib/utils/error_mappers.dart
+++ b/player-android/lib/utils/error_mappers.dart
@@ -259,6 +259,32 @@ String folderErrorMessage(Object error) {
return 'Unexpected error. Please try again.';
}
+/// Maps any thrown object from [PlayerApiClient.getSharedMediaPage] to a UI string.
+///
+/// Adds human-readable messages for the status codes the share-viewer endpoint
+/// can return:
+/// - 404: the share token does not exist (never created, or already deleted).
+/// - 410: the share has expired (server-side expiry or max-uses exceeded).
+///
+/// These two cases are shown with distinct messages so the viewer knows whether
+/// the link was invalid from the start or whether it was valid but has since
+/// expired. All other failures fall back to [dioConnectionErrorMessage].
+///
+/// Kept as a separate top-level function (Open-Closed, DRY) so it can evolve
+/// independently of other error mappers.
+String shareViewerErrorMessage(Object error) {
+ if (error is DioException) {
+ if (error.response?.statusCode == 404) {
+ return 'This share link is invalid or has been revoked.';
+ }
+ if (error.response?.statusCode == 410) {
+ return 'This share link has expired.';
+ }
+ return dioConnectionErrorMessage(error);
+ }
+ return 'Unexpected error. Please try again.';
+}
+
/// Maps any thrown object from [PlayerApiClient.listEpisodes] to a UI string.
///
/// Adds a 404-specific message (podcast set not found) on top of the generic
diff --git a/player-android/test/screens/share_viewer_screen_test.dart b/player-android/test/screens/share_viewer_screen_test.dart
new file mode 100644
index 0000000..2c048d9
--- /dev/null
+++ b/player-android/test/screens/share_viewer_screen_test.dart
@@ -0,0 +1,443 @@
+// Widget tests for ShareViewerScreen (share_viewer_screen.dart).
+//
+// Tests cover:
+// 1. Shows a loading indicator while getSharedMediaPage is in flight.
+// 2. Renders filename, type, and duration after a successful load.
+// 3. Shows the thumbnail placeholder when hasThumb is false.
+// 4. Shows the play button after a successful load.
+// 5. Shows a 404 error message for an invalid/revoked token.
+// 6. Shows a 410 error message for an expired token.
+// 7. Shows a generic error message for network failures.
+// 8. Shows the retry button on error and re-calls getSharedMediaPage on tap.
+//
+// Riverpod providers are overridden with fakes so tests run without a real
+// server. GoRouter is replaced with a plain MaterialApp to avoid test
+// infrastructure complexity.
+//
+// Run with: flutter test test/screens/share_viewer_screen_test.dart
+
+import 'dart:async';
+
+import 'package:dio/dio.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:player_android/api/player_api_client.dart';
+import 'package:player_android/providers/public_api_client_provider.dart';
+import 'package:player_android/screens/share_viewer_screen.dart';
+import 'package:player_android/utils/error_mappers.dart';
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+/// Controllable [PlayerApiClient] stub for [ShareViewerScreen] tests.
+///
+/// Overrides the two members that [ShareViewerScreen] actually calls —
+/// [getSharedMediaPage] and [baseUrl] — making the contract explicit rather
+/// than relying on the concrete base class throwing [UnimplementedError] for
+/// the many methods the screen never touches (LSP / ISP: the fake honours
+/// the subset of the contract the screen depends on).
+class _FakePublicApiClient extends PlayerApiClient {
+ _FakePublicApiClient()
+ : super(dio: Dio(BaseOptions(baseUrl: 'http://test.local')));
+
+ // When non-null, [getSharedMediaPage] returns this string.
+ String? pageJson;
+
+ // When non-null, [getSharedMediaPage] throws this instead of returning.
+ Object? pageError;
+
+ // Count of calls to [getSharedMediaPage] so retry tests can assert call count.
+ int callCount = 0;
+
+ /// Returns the test base URL without a trailing slash, matching the
+ /// production [PlayerApiClient.baseUrl] contract used in [ShareViewerScreen].
+ @override
+ String get baseUrl => 'http://test.local';
+
+ @override
+ Future<String> getSharedMediaPage(String token) async {
+ callCount++;
+ if (pageError != null) throw pageError!;
+ return pageJson!;
+ }
+}
+
+/// Controllable [PlayerApiClient] stub that delays [getSharedMediaPage] until
+/// [complete] is called — used to inspect the mid-flight loading state.
+///
+/// Overrides [baseUrl] explicitly for the same reason as [_FakePublicApiClient]:
+/// the screen calls [baseUrl] when constructing the thumbnail URL, so the stub
+/// must provide a consistent value rather than delegating to [rawDio] internals.
+class _DelayedFakePublicApiClient extends PlayerApiClient {
+ _DelayedFakePublicApiClient()
+ : super(dio: Dio(BaseOptions(baseUrl: 'http://test.local')));
+
+ final _completer = Completer<String>();
+
+ void complete(String json) => _completer.complete(json);
+
+ /// Returns the test base URL without a trailing slash.
+ @override
+ String get baseUrl => 'http://test.local';
+
+ @override
+ Future<String> getSharedMediaPage(String token) => _completer.future;
+}
+
+// ---------------------------------------------------------------------------
+// Sample JSON
+// ---------------------------------------------------------------------------
+
+/// Valid share-page JSON for a video file with a thumbnail.
+const _kVideoShareJson = '''
+{
+ "media": {
+ "id": 42,
+ "file_name": "holiday.mp4",
+ "type": "video",
+ "duration": 3612.5
+ },
+ "has_thumb": true,
+ "stream_url": "/s/abc123/stream",
+ "download_url": "/s/abc123/download",
+ "thumb_url": "/s/abc123/thumbnail"
+}
+''';
+
+/// Valid share-page JSON for an audio file without a thumbnail.
+const _kAudioShareJson = '''
+{
+ "media": {
+ "id": 7,
+ "file_name": "podcast.mp3",
+ "type": "audio",
+ "duration": 1800.0
+ },
+ "has_thumb": false,
+ "stream_url": "/s/tok7/stream",
+ "download_url": "/s/tok7/download",
+ "thumb_url": ""
+}
+''';
+
+// ---------------------------------------------------------------------------
+// Helper: pump ShareViewerScreen inside a ProviderScope.
+// ---------------------------------------------------------------------------
+
+/// Pumps [ShareViewerScreen] inside a [ProviderScope] that overrides
+/// [publicApiClientProvider] with a fake, wrapped in a [MaterialApp] so
+/// widgets like SnackBar and routes work correctly.
+///
+/// [goRouterOverride] is passed as the [MaterialApp] router if supplied;
+/// the default is a plain [MaterialApp] with no