summaryrefslogtreecommitdiff
path: root/player-android/lib/screens
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 /player-android/lib/screens
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>
Diffstat (limited to 'player-android/lib/screens')
-rw-r--r--player-android/lib/screens/share_viewer_screen.dart429
1 files changed, 429 insertions, 0 deletions
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'),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}