Skip to content

Commit 4ed006a

Browse files
ehsan6shaclaude
andcommitted
Web: Shelf, Websites, Tags, Playlists (read/consume v1)
Four native features arrive on web as view-first mirrors of their native screens (per the screens-mirror-native rule; each file headers the native counterpart it mirrors): - Shelf (mirror of shelf_screen): same grid metrics (180px / 0.70), tile anatomy (category block, autoTitle, two-line description) and empty-state copy. Notes open from manifest text; images/video/audio/ text preview in-place via the shared MediaPreviewDialog (extracted to lib/web/widgets/); other types download. Capture/reorder stay native. - Websites (mirror of websites_browser_screen): globe rows with stripped display names, published-state subtitle, copy/open of the live link - stable IPNS front door first, then result-CID gateway. - Tags (mirror of tags_browser_screen + tagged_files_screen): search, color-dot rows with counts, per-tag file lists with relative Tagged-ago times and cloud/device badges. - Playlists (mirror of playlists_screen + playlist_detail_screen): 64x64 list-music cards with track counts/durations, numbered track rows, playback through the web audio player (audio-v8-first track resolution); native LIST-merge semantics incl. v8 tombstones. WebFeatures (lib/web/services/web_features.dart) implements each native service cloud restore path: downloadMetadataMerged manifests (v8-first id-wins) for shelf/websites/tags, LIST-merge + tombstone subtraction for playlists, sha256(publicKey)[0:16] manifest scoping. Home gains the four feature chips under the category grid. Gates: dart analyze 176, flutter test 466 green, web build green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 05a5353 commit 4ed006a

9 files changed

Lines changed: 1520 additions & 169 deletions

‎lib/web/router_web.dart‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import 'package:go_router/go_router.dart';
22

33
import 'package:fula_files/web/screens/web_bucket_screen.dart';
44
import 'package:fula_files/web/screens/web_home_screen.dart';
5+
import 'package:fula_files/web/screens/web_playlists_screen.dart';
6+
import 'package:fula_files/web/screens/web_shelf_screen.dart';
57
import 'package:fula_files/web/screens/web_signin_screen.dart';
8+
import 'package:fula_files/web/screens/web_tags_screen.dart';
9+
import 'package:fula_files/web/screens/web_websites_screen.dart';
610
import 'package:fula_files/web/services/web_session.dart';
711

812
/// Web-shell router. Deliberately defines ONLY the cloud routes — the
@@ -35,6 +39,34 @@ GoRouter buildWebRouter() {
3539
base: state.pathParameters['base'] ?? 'documents',
3640
),
3741
),
42+
GoRoute(
43+
path: '/shelf',
44+
builder: (context, state) => const WebShelfScreen(),
45+
),
46+
GoRoute(
47+
path: '/websites',
48+
builder: (context, state) => const WebWebsitesScreen(),
49+
),
50+
GoRoute(
51+
path: '/tags',
52+
builder: (context, state) => const WebTagsScreen(),
53+
),
54+
GoRoute(
55+
path: '/tags/:id',
56+
builder: (context, state) => WebTaggedFilesScreen(
57+
tagId: state.pathParameters['id'] ?? '',
58+
),
59+
),
60+
GoRoute(
61+
path: '/playlists',
62+
builder: (context, state) => const WebPlaylistsScreen(),
63+
),
64+
GoRoute(
65+
path: '/playlist/:id',
66+
builder: (context, state) => WebPlaylistDetailScreen(
67+
playlistId: state.pathParameters['id'] ?? '',
68+
),
69+
),
3870
],
3971
);
4072
}

‎lib/web/screens/web_bucket_screen.dart‎

Lines changed: 2 additions & 169 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,20 @@ import 'dart:async';
22
import 'dart:convert';
33
import 'dart:math';
44

5-
import 'package:chewie/chewie.dart';
65
import 'package:file_picker/file_picker.dart';
76
import 'package:flutter/material.dart';
87
import 'package:flutter/services.dart';
98
import 'package:fula_client/fula_client.dart' as fula;
109
import 'package:go_router/go_router.dart';
11-
import 'package:just_audio/just_audio.dart';
1210
import 'package:uuid/uuid.dart';
13-
import 'package:video_player/video_player.dart';
1411

1512
import 'package:fula_files/core/models/fula_object.dart';
1613
import 'package:fula_files/core/models/share_token.dart' as share_model;
1714
import 'package:fula_files/core/services/bucket_version_resolver.dart';
1815
import 'package:fula_files/core/services/fula_api_service.dart';
1916
import 'package:fula_files/core/services/share_link_builder.dart';
2017
import 'package:fula_files/web/services/web_save.dart';
18+
import 'package:fula_files/web/widgets/media_preview_dialog.dart';
2119

2220
/// Hard per-file upload cap for web v1: picked files are held fully in
2321
/// browser memory before the encrypted upload, so bound the worst case.
@@ -584,7 +582,7 @@ class _WebBucketScreenState extends State<WebBucketScreen> {
584582
if (!mounted) return;
585583
await showDialog<void>(
586584
context: context,
587-
builder: (ctx) => _MediaPreviewDialog(
585+
builder: (ctx) => MediaPreviewDialog(
588586
title: _displayName(o),
589587
bytes: bytes,
590588
mimeType: _mediaMime(o),
@@ -759,168 +757,3 @@ class _WebBucketScreenState extends State<WebBucketScreen> {
759757
}
760758
}
761759

762-
/// Plays decrypted bytes through the browser media stack: bytes -> Blob
763-
/// object URL -> HTML5 <video>/<audio> (via video_player / just_audio).
764-
/// Codec support is the browser's own; Download stays one click away.
765-
class _MediaPreviewDialog extends StatefulWidget {
766-
final String title;
767-
final Uint8List bytes;
768-
final String mimeType;
769-
final bool isVideo;
770-
771-
const _MediaPreviewDialog({
772-
required this.title,
773-
required this.bytes,
774-
required this.mimeType,
775-
required this.isVideo,
776-
});
777-
778-
@override
779-
State<_MediaPreviewDialog> createState() => _MediaPreviewDialogState();
780-
}
781-
782-
class _MediaPreviewDialogState extends State<_MediaPreviewDialog> {
783-
late final String _blobUrl =
784-
createBlobUrl(widget.bytes, mimeType: widget.mimeType);
785-
VideoPlayerController? _video;
786-
ChewieController? _chewie;
787-
AudioPlayer? _audio;
788-
bool _ready = false;
789-
String? _error;
790-
791-
@override
792-
void initState() {
793-
super.initState();
794-
_init();
795-
}
796-
797-
Future<void> _init() async {
798-
try {
799-
if (widget.isVideo) {
800-
final video = VideoPlayerController.networkUrl(Uri.parse(_blobUrl));
801-
await video.initialize();
802-
_video = video;
803-
_chewie = ChewieController(
804-
videoPlayerController: video,
805-
autoPlay: true,
806-
looping: false,
807-
);
808-
} else {
809-
final audio = AudioPlayer();
810-
await audio.setUrl(_blobUrl);
811-
_audio = audio;
812-
unawaited(audio.play());
813-
}
814-
if (mounted) setState(() => _ready = true);
815-
} catch (e) {
816-
if (mounted) setState(() => _error = '$e');
817-
}
818-
}
819-
820-
@override
821-
void dispose() {
822-
_chewie?.dispose();
823-
_video?.dispose();
824-
_audio?.dispose();
825-
revokeBlobUrl(_blobUrl);
826-
super.dispose();
827-
}
828-
829-
String _fmtDur(Duration d) {
830-
final m = d.inMinutes;
831-
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
832-
return '$m:$s';
833-
}
834-
835-
@override
836-
Widget build(BuildContext context) {
837-
return Dialog(
838-
child: ConstrainedBox(
839-
constraints: const BoxConstraints(maxWidth: 900, maxHeight: 700),
840-
child: Column(
841-
mainAxisSize: MainAxisSize.min,
842-
children: [
843-
ListTile(
844-
title: Text(widget.title, overflow: TextOverflow.ellipsis),
845-
trailing: Row(
846-
mainAxisSize: MainAxisSize.min,
847-
children: [
848-
IconButton(
849-
tooltip: 'Download',
850-
icon: const Icon(Icons.download),
851-
onPressed: () => saveBytesAsDownload(
852-
widget.title.split('/').last,
853-
widget.bytes,
854-
mimeType: widget.mimeType,
855-
),
856-
),
857-
IconButton(
858-
tooltip: 'Close',
859-
icon: const Icon(Icons.close),
860-
onPressed: () => Navigator.pop(context),
861-
),
862-
],
863-
),
864-
),
865-
if (_error != null)
866-
Padding(
867-
padding: const EdgeInsets.all(24),
868-
child: Text(
869-
'This browser could not play the file '
870-
'(${widget.mimeType}). Use Download instead.\n$_error',
871-
textAlign: TextAlign.center,
872-
),
873-
)
874-
else if (!_ready)
875-
const Padding(
876-
padding: EdgeInsets.all(48),
877-
child: CircularProgressIndicator(),
878-
)
879-
else if (widget.isVideo)
880-
Flexible(
881-
child: AspectRatio(
882-
aspectRatio: _video!.value.aspectRatio == 0
883-
? 16 / 9
884-
: _video!.value.aspectRatio,
885-
child: Chewie(controller: _chewie!),
886-
),
887-
)
888-
else
889-
Padding(
890-
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
891-
child: Row(
892-
mainAxisSize: MainAxisSize.min,
893-
children: [
894-
StreamBuilder<PlayerState>(
895-
stream: _audio!.playerStateStream,
896-
builder: (ctx, snap) {
897-
final playing = snap.data?.playing ?? false;
898-
return IconButton.filled(
899-
iconSize: 36,
900-
icon: Icon(
901-
playing ? Icons.pause : Icons.play_arrow),
902-
onPressed: () =>
903-
playing ? _audio!.pause() : _audio!.play(),
904-
);
905-
},
906-
),
907-
const SizedBox(width: 16),
908-
StreamBuilder<Duration>(
909-
stream: _audio!.positionStream,
910-
builder: (ctx, snap) {
911-
final pos = snap.data ?? Duration.zero;
912-
final total = _audio!.duration ?? Duration.zero;
913-
return Text(
914-
'${_fmtDur(pos)} / ${_fmtDur(total)}');
915-
},
916-
),
917-
],
918-
),
919-
),
920-
const SizedBox(height: 8),
921-
],
922-
),
923-
),
924-
);
925-
}
926-
}

‎lib/web/screens/web_home_screen.dart‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,27 @@ class _WebHomeScreenState extends State<WebHomeScreen> {
113113
),
114114
],
115115
),
116+
Padding(
117+
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
118+
child: Wrap(
119+
spacing: 12,
120+
runSpacing: 12,
121+
alignment: WrapAlignment.center,
122+
children: [
123+
for (final f in const [
124+
('/shelf', 'Shelf', Icons.inbox_outlined),
125+
('/websites', 'Websites', Icons.public),
126+
('/tags', 'Tags', Icons.sell_outlined),
127+
('/playlists', 'Playlists', Icons.queue_music),
128+
])
129+
ActionChip(
130+
avatar: Icon(f.$3, size: 18),
131+
label: Text(f.$2),
132+
onPressed: () => context.go(f.$1),
133+
),
134+
],
135+
),
136+
),
116137
FutureBuilder<StorageInfo>(
117138
future: _storage,
118139
builder: (ctx, snap) {

0 commit comments

Comments
 (0)