From 02a841b4a4e9dfe7797a38d6bc85805a9905d0ce Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Sun, 14 Jun 2026 12:54:42 -0400 Subject: [PATCH] Web Audio: full player + add-to-playlist / create playlist like mobile (#21) Replace the bare audio dialog on the web app with a full mobile-style player, and let users add tracks to playlists / create playlists from the web (cloud-backed, cross-device with the native app). New WebAudioPlayer (full-screen via Dialog.fullscreen, like the image and text previews) for AUDIO; video stays on Chewie. Now-playing, seek slider, play/pause, skip prev/next, rewind/forward 10s, repeat (off/one/all), shuffle, a tappable queue, add-to-playlist and download. New WebAudioController (just_audio): queue is the current folder audio files (start at the tapped track) or a playlist tracks; downloads on demand to a Blob URL then setUrl/play, revoking the previous blob on every track change and on dispose (a queue churns many blobs); repeat-one replays without re-downloading. New cloud playlist WRITE (WebPlaylistService, additive): create and add build Playlist.toJson and call the SAME shared encryptAndUpload native uses (writeBucket playlists, user-playlists/.json), so native and web read each other playlists. Add-to-existing loads, appends (dedup by path), re-PUTs the single per-id object. Never deletes, never writes tombstones. Playlist-detail playback unified onto the new player. Pure logic (queue/repeat/shuffle transitions; playlist build/append/ serialize) lives in web_audio_queue.dart + web_playlist_write_logic.dart and is VM-unit-tested (17 tests incl. a Playlist.fromJson round-trip); the just_audio/blob glue and the cloud write are verified live. Encryption parity is by construction and empirical: the same shared encryptAndUpload core runs on both platforms keyed off the session, and web already decrypts native-written playlists. Concurrency is last-writer-wins per object, identical to native fire-and-forget sync. Reviewed by Gemini + the built-in advisor (Codex/Cursor/Copilot all rate-limited this session). Applied: aggressive blob revocation, cloudKey-based shuffle lookup, repeat-one replay-without-reload, _ensureBucket error propagation. analyze clean, full suite green (537), web build green. Co-Authored-By: Claude Opus 4.8 --- lib/web/screens/web_bucket_screen.dart | 46 ++ lib/web/screens/web_playlists_screen.dart | 48 +- lib/web/services/web_audio_controller.dart | 202 ++++++++ lib/web/services/web_audio_queue.dart | 71 +++ lib/web/services/web_playlist_service.dart | 97 ++++ .../services/web_playlist_write_logic.dart | 49 ++ lib/web/widgets/web_audio_player.dart | 437 ++++++++++++++++++ test/unit/web/web_audio_queue_test.dart | 78 ++++ .../web/web_playlist_write_logic_test.dart | 72 +++ 9 files changed, 1079 insertions(+), 21 deletions(-) create mode 100644 lib/web/services/web_audio_controller.dart create mode 100644 lib/web/services/web_audio_queue.dart create mode 100644 lib/web/services/web_playlist_service.dart create mode 100644 lib/web/services/web_playlist_write_logic.dart create mode 100644 lib/web/widgets/web_audio_player.dart create mode 100644 test/unit/web/web_audio_queue_test.dart create mode 100644 test/unit/web/web_playlist_write_logic_test.dart diff --git a/lib/web/screens/web_bucket_screen.dart b/lib/web/screens/web_bucket_screen.dart index c23b2c8..d2dd188 100644 --- a/lib/web/screens/web_bucket_screen.dart +++ b/lib/web/screens/web_bucket_screen.dart @@ -15,6 +15,7 @@ import 'package:fula_files/core/services/bucket_version_resolver.dart'; import 'package:fula_files/core/services/collaboration_service.dart'; import 'package:fula_files/core/services/fula_api_service.dart'; import 'package:fula_files/core/services/ipfs_public_service.dart'; +import 'package:fula_files/web/services/web_audio_controller.dart'; import 'package:fula_files/web/services/web_cache_sync.dart'; import 'package:fula_files/web/services/web_foreground_activity.dart'; import 'package:fula_files/web/services/web_listing_cache.dart'; @@ -26,6 +27,7 @@ import 'package:fula_files/web/services/web_tag_service.dart'; import 'package:fula_files/web/services/web_text_viewer_logic.dart'; import 'package:fula_files/web/services/web_upload_manager.dart'; import 'package:fula_files/web/widgets/media_preview_dialog.dart'; +import 'package:fula_files/web/widgets/web_audio_player.dart'; import 'package:fula_files/web/widgets/web_create_share_dialog.dart'; import 'package:fula_files/web/widgets/web_tag_dialogs.dart'; import 'package:fula_files/web/widgets/web_text_viewer.dart'; @@ -795,6 +797,11 @@ class _WebBucketScreenState extends State { /// codec support depends on the browser; the dialog offers Download /// as the fallback. Future _previewMedia(FulaObject o) async { + // Audio → the full-screen queue player (#21); video stays on Chewie. + if (_isAudio(o)) { + await _openAudioPlayer(o); + return; + } _snack('Loading "${_displayName(o)}"…'); try { final bucket = o.sourceBucket ?? widget.base; @@ -816,6 +823,45 @@ class _WebBucketScreenState extends State { } } + /// A player queue item for an audio object in this bucket (the controller + /// downloads on demand, so nothing is fetched until it plays). + WebAudioTrack _audioTrackFor(FulaObject o) { + final bucket = o.sourceBucket ?? widget.base; + return WebAudioTrack( + name: _displayName(o).split('/').last, + mime: _mediaMime(o), + cloudKey: o.key, + download: () => FulaApiService.instance.downloadObject(bucket, o.key), + ); + } + + /// Open the full-screen audio player with a queue of every audio file in + /// the current listing, starting at [tapped] (#21). + Future _openAudioPlayer(FulaObject tapped) async { + final audio = (_objects ?? const []).where(_isAudio).toList(); + final tappedBucket = tapped.sourceBucket ?? widget.base; + var start = audio.indexWhere((o) => + o.key == tapped.key && + (o.sourceBucket ?? widget.base) == tappedBucket); + if (start < 0) { + // Not in the current listing (e.g. a deep-open) — play it on its own. + audio.insert(0, tapped); + start = 0; + } + _recordRecent(tapped, tappedBucket); + if (!mounted) return; + await showDialog( + context: context, + useSafeArea: false, + builder: (ctx) => Dialog.fullscreen( + child: WebAudioPlayer( + queue: [for (final o in audio) _audioTrackFor(o)], + startIndex: start, + ), + ), + ); + } + void _snack(String msg) { if (!mounted) return; ScaffoldMessenger.of(context) diff --git a/lib/web/screens/web_playlists_screen.dart b/lib/web/screens/web_playlists_screen.dart index 406c128..4a8ac83 100644 --- a/lib/web/screens/web_playlists_screen.dart +++ b/lib/web/screens/web_playlists_screen.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:fula_files/core/models/playlist.dart'; +import 'package:fula_files/web/services/web_audio_controller.dart'; import 'package:fula_files/web/services/web_features.dart'; -import 'package:fula_files/web/widgets/media_preview_dialog.dart'; +import 'package:fula_files/web/widgets/web_audio_player.dart'; /// Mirror of lib/features/audio/screens/playlists_screen.dart /// (view-only): 64x64 list-music cover, name + "N tracks · duration" @@ -164,27 +165,32 @@ class _WebPlaylistDetailScreenState extends State { } } - Future _play(AudioTrack track) async { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Loading "${track.name}"…'))); - try { - final bytes = await WebFeatures.downloadTrack(track); - if (!mounted) return; - await showDialog( - context: context, - builder: (ctx) => MediaPreviewDialog( - title: track.name, - bytes: bytes, - mimeType: 'audio/mpeg', - isVideo: false, + /// Play the playlist through the full-screen web audio player (#21), with + /// the whole playlist as the queue starting at [track]. Tracks download on + /// demand inside the player. + void _play(AudioTrack track) { + final p = _playlist; + if (p == null || p.tracks.isEmpty) return; + var start = p.tracks.indexWhere((t) => t.path == track.path); + if (start < 0) start = 0; + showDialog( + context: context, + useSafeArea: false, + builder: (ctx) => Dialog.fullscreen( + child: WebAudioPlayer( + queue: [ + for (final t in p.tracks) + WebAudioTrack( + name: t.name, + mime: 'audio/mpeg', + cloudKey: t.path, + download: () => WebFeatures.downloadTrack(t), + ), + ], + startIndex: start, ), - ); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('Could not play "${track.name}": this track is ' - 'not in cloud audio storage.'))); - } + ), + ); } @override diff --git a/lib/web/services/web_audio_controller.dart b/lib/web/services/web_audio_controller.dart new file mode 100644 index 0000000..9c68e2e --- /dev/null +++ b/lib/web/services/web_audio_controller.dart @@ -0,0 +1,202 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:just_audio/just_audio.dart'; + +import 'package:fula_files/web/services/web_audio_queue.dart'; +import 'package:fula_files/web/services/web_save.dart'; + +/// One queue item for the web audio player. [download] fetches the decrypted +/// bytes (the source — a bucket object or a playlist track — is captured by +/// the closure, so the controller is source-agnostic); [cloudKey] is the +/// object key to persist when adding this track to a playlist. +class WebAudioTrack { + final String name; + final String mime; + final String cloudKey; + final Future Function() download; + const WebAudioTrack({ + required this.name, + required this.mime, + required this.cloudKey, + required this.download, + }); +} + +/// Drives web audio playback over a queue: download → Blob object URL → +/// just_audio `setUrl` → play, advancing per repeat / shuffle. Blob URLs are +/// revoked aggressively (a queue creates many over its life — an unrevoked +/// blob leaks and can crash mobile browsers). Pure transitions come from +/// web_audio_queue.dart; this is the browser playback glue. +class WebAudioController extends ChangeNotifier { + final AudioPlayer _player = AudioPlayer(); + AudioPlayer get player => _player; + + List _queue = const []; + List _originalOrder = const []; + int _index = -1; + WebRepeatMode _repeat = WebRepeatMode.off; + bool _shuffle = false; + bool _disposed = false; + + String? _currentBlobUrl; + int _loadToken = 0; // guards against out-of-order async loads + + final Random _rng = Random(); + StreamSubscription? _procSub; + + WebAudioController() { + _procSub = _player.processingStateStream.listen((s) { + if (s == ProcessingState.completed) _onComplete(); + }); + } + + List get queue => _queue; + int get index => _index; + WebAudioTrack? get current => + (_index >= 0 && _index < _queue.length) ? _queue[_index] : null; + WebRepeatMode get repeatMode => _repeat; + bool get shuffle => _shuffle; + + Future playQueue(List tracks, int startIndex) async { + _originalOrder = List.of(tracks); + _queue = List.of(tracks); + _index = (startIndex >= 0 && startIndex < _queue.length) ? startIndex : 0; + if (_shuffle) _applyShuffle(); + notifyListeners(); + await _loadAndPlay(_index); + } + + Future jumpTo(int i) => _loadAndPlay(i); + + Future _loadAndPlay(int i) async { + if (i < 0 || i >= _queue.length) return; + _index = i; + notifyListeners(); + final token = ++_loadToken; + final track = _queue[i]; + try { + final bytes = await track.download(); + if (_disposed || token != _loadToken) return; // superseded by a newer load + _swapBlob(createBlobUrl(bytes, mimeType: track.mime)); + await _player.setUrl(_currentBlobUrl!); + if (_disposed || token != _loadToken) return; + await _player.play(); + } catch (e) { + debugPrint('WebAudioController._loadAndPlay($i): $e'); + } + } + + /// Adopt [url] as the current blob and revoke the previous one immediately. + void _swapBlob(String url) { + final old = _currentBlobUrl; + _currentBlobUrl = url; + if (old != null) { + try { + revokeBlobUrl(old); + } catch (_) {} + } + } + + void _onComplete() { + final next = nextIndexOnComplete(_index, _queue.length, _repeat); + if (next == null) return; // end of queue, no repeat → stop + if (next == _index) { + // repeat-one → replay without re-downloading the same blob. + _player.seek(Duration.zero); + _player.play(); + } else { + _loadAndPlay(next); + } + } + + Future playPause() => + _player.playing ? _player.pause() : _player.play(); + + Future next() async { + final n = nextIndexManual(_index, _queue.length, _repeat); + if (n != null) await _loadAndPlay(n); + } + + Future previous() async { + // Past the first few seconds → restart the track, else go to the previous. + if (_player.position > const Duration(seconds: 3)) { + await _player.seek(Duration.zero); + return; + } + await _loadAndPlay(prevIndexManual(_index, _queue.length, _repeat)); + } + + Future seek(Duration d) => _player.seek(d); + + Future rewind() async { + final p = _player.position - const Duration(seconds: 10); + await _player.seek(p < Duration.zero ? Duration.zero : p); + } + + Future forward() async { + final dur = _player.duration ?? Duration.zero; + final p = _player.position + const Duration(seconds: 10); + await _player.seek(p > dur ? dur : p); + } + + void cycleRepeat() { + _repeat = nextRepeatMode(_repeat); + notifyListeners(); + } + + void toggleShuffle() { + _shuffle = !_shuffle; + if (_shuffle) { + _applyShuffle(); + } else { + _restoreOrder(); + } + notifyListeners(); + } + + /// Find a track by its stable cloudKey (more robust than identity, which + /// would break if a caller ever rebuilt the track list — advisor: Gemini). + int _indexOfKey(List list, String? key) { + if (key == null) return -1; + for (var i = 0; i < list.length; i++) { + if (list[i].cloudKey == key) return i; + } + return -1; + } + + /// Reorder [_queue] to a shuffle of the original order with the current + /// track first (so playback doesn't jump), mirroring native. + void _applyShuffle() { + if (_originalOrder.isEmpty) return; + final curKey = current?.cloudKey; + final order = buildShuffleOrder( + _originalOrder.length, _indexOfKey(_originalOrder, curKey), _rng); + _queue = [for (final i in order) _originalOrder[i]]; + final ni = _indexOfKey(_queue, curKey); + _index = ni < 0 ? 0 : ni; + } + + void _restoreOrder() { + final curKey = current?.cloudKey; + _queue = List.of(_originalOrder); + final ni = _indexOfKey(_queue, curKey); + _index = ni < 0 ? 0 : ni; + } + + @override + void dispose() { + _disposed = true; + _procSub?.cancel(); + _player.dispose(); + final url = _currentBlobUrl; + _currentBlobUrl = null; + if (url != null) { + try { + revokeBlobUrl(url); + } catch (_) {} + } + super.dispose(); + } +} diff --git a/lib/web/services/web_audio_queue.dart b/lib/web/services/web_audio_queue.dart new file mode 100644 index 0000000..e31da6a --- /dev/null +++ b/lib/web/services/web_audio_queue.dart @@ -0,0 +1,71 @@ +import 'dart:math'; + +/// Pure, VM-testable queue / repeat / shuffle logic for the web audio player +/// (#21), mirroring the framework-agnostic core of the native +/// `audio_player_service.dart`. No `package:web` / just_audio imports so this +/// — and its unit tests — run under the VM; the playback glue (blob URLs, +/// just_audio) lives in `web_audio_controller.dart`. + +/// Repeat behaviour, matching native `RepeatMode` (off → one → all). +enum WebRepeatMode { off, one, all } + +/// Cycle order for the repeat button: off → one → all → off (native order). +WebRepeatMode nextRepeatMode(WebRepeatMode m) { + switch (m) { + case WebRepeatMode.off: + return WebRepeatMode.one; + case WebRepeatMode.one: + return WebRepeatMode.all; + case WebRepeatMode.all: + return WebRepeatMode.off; + } +} + +/// Index to play when the current track finishes, or null to stop. +/// (one → replay current; all → wrap; off → next or stop at the end.) +int? nextIndexOnComplete(int current, int length, WebRepeatMode mode) { + if (length <= 0) return null; + switch (mode) { + case WebRepeatMode.one: + return current; + case WebRepeatMode.all: + return (current + 1) % length; + case WebRepeatMode.off: + return current + 1 < length ? current + 1 : null; + } +} + +/// Index for a manual "next" tap, or null if at the end without repeat-all. +/// (Manual next ignores repeat-one — it advances, like native skipToNext.) +int? nextIndexManual(int current, int length, WebRepeatMode mode) { + if (length <= 0) return null; + if (current >= length - 1) return mode == WebRepeatMode.all ? 0 : null; + return current + 1; +} + +/// Index for a manual "previous" tap (wraps to the last track under +/// repeat-all, else clamps at the first). +int prevIndexManual(int current, int length, WebRepeatMode mode) { + if (length <= 0) return 0; + if (current > 0) return current - 1; + return mode == WebRepeatMode.all ? length - 1 : 0; +} + +/// A shuffle of the indices `[0, length)` with [current] moved to the front, +/// so the playing track stays put when shuffle is toggled on (mirrors native +/// `_shufflePlaylist(keepCurrent: true)`). [rng] is injected for testability. +List buildShuffleOrder(int length, int current, Random rng) { + final order = List.generate(length, (i) => i)..shuffle(rng); + if (current >= 0 && current < length) { + order.remove(current); + order.insert(0, current); + } + return order; +} + +/// `m:ss` like the native player's time labels. +String fmtDuration(Duration d) { + final m = d.inMinutes; + final s = (d.inSeconds % 60).toString().padLeft(2, '0'); + return '$m:$s'; +} diff --git a/lib/web/services/web_playlist_service.dart b/lib/web/services/web_playlist_service.dart new file mode 100644 index 0000000..b24ab82 --- /dev/null +++ b/lib/web/services/web_playlist_service.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:fula_files/core/models/playlist.dart'; +import 'package:fula_files/core/services/bucket_version_resolver.dart'; +import 'package:fula_files/core/services/fula_api_service.dart'; +import 'package:fula_files/core/services/secure_storage_service.dart'; +import 'package:fula_files/web/services/web_features.dart'; +import 'package:fula_files/web/services/web_playlist_write_logic.dart'; + +/// Web cloud playlist WRITE path (#21): create a playlist / add a track, +/// persisted as the SAME encrypted `user-playlists/.json` object the +/// native app reads & writes — so playlists round-trip across web and mobile. +/// +/// Minimal blast radius by construction: only ever PUTs a single per-id +/// playlist object through the shared `encryptAndUpload` with native-identical +/// bucket / key / format / contentType. It never deletes and never writes +/// tombstones — those stay native-only. The pure transforms +/// (build / append / serialize) live in web_playlist_write_logic.dart and are +/// VM-unit-tested; this file is the cloud IO glue. +class WebPlaylistService { + WebPlaylistService._(); + static final WebPlaylistService instance = WebPlaylistService._(); + + static const _uuid = Uuid(); + + /// The bucket writes route to — the SAME resolver native uses, so the + /// write always lands in a bucket the read-merge covers (`playlists-v8` + /// once managed, else legacy `playlists`). + String get _writeBucket => BucketVersionResolver.writeBucket('playlists'); + + Future _kek() async { + final b64 = await SecureStorageService.instance + .read(SecureStorageKeys.encryptionKey); + if (b64 == null || b64.isEmpty) { + throw StateError('No session encryption key'); + } + return Uint8List.fromList(base64Decode(b64)); + } + + Future _ensureBucket(String bucket) async { + // Propagate failures to the caller (which surfaces a snackbar) rather + // than swallowing and uploading into a missing/forbidden bucket — clearer + // errors, and matches native syncPlaylistToCloud (advisor: Gemini). + // createBucket itself tolerates an already-existing bucket. + if (!await FulaApiService.instance.bucketExists(bucket)) { + await FulaApiService.instance.createBucket(bucket); + } + } + + Future _put(Playlist p, Uint8List kek) async { + final bucket = _writeBucket; + await _ensureBucket(bucket); + await FulaApiService.instance.encryptAndUpload( + bucket, + playlistCloudKey(p.id), // canonical key → always in a read-merged bucket + playlistUploadBytes(p), + kek, + contentType: 'application/json', + ); + } + + /// Create a new playlist (optionally seeded with [tracks]) and upload it. + Future createPlaylist(String name, + {List tracks = const []}) async { + final kek = await _kek(); + final p = buildNewPlaylist( + id: _uuid.v4(), + name: name, + tracks: List.from(tracks), + now: DateTime.now(), + ); + await _put(p, kek); + return p; + } + + /// Add [track] to an existing playlist (by id): load the current cloud + /// copy, append (dedup by path), re-upload. Returns false if the playlist + /// is missing or the track was already present (no upload). + Future addTrackToPlaylist(String playlistId, AudioTrack track) async { + final kek = await _kek(); + final all = await WebFeatures.loadPlaylists(); + Playlist? target; + for (final p in all) { + if (p.id == playlistId) { + target = p; + break; + } + } + if (target == null) return false; + if (!appendTrack(target, track, DateTime.now())) return false; + await _put(target, kek); + return true; + } +} diff --git a/lib/web/services/web_playlist_write_logic.dart b/lib/web/services/web_playlist_write_logic.dart new file mode 100644 index 0000000..150c76b --- /dev/null +++ b/lib/web/services/web_playlist_write_logic.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:fula_files/core/models/playlist.dart'; + +/// Pure, VM-testable transforms behind the web cloud playlist WRITE path +/// (#21). No `package:web` / FulaApiService imports so this — and its unit +/// tests — run under the VM; the IO glue (encrypt + upload + bucket ensure) +/// lives in `web_playlist_service.dart`. +/// +/// CRITICAL: the on-disk shape must stay byte-compatible with what native +/// `PlaylistService` writes and `WebFeatures.loadPlaylists` reads, so this +/// goes through the shared `Playlist`/`AudioTrack` models' `toJson` only. + +/// The playlist object-key prefix native uses (PlaylistService._playlistPrefix). +const String kPlaylistPrefix = 'user-playlists/'; + +/// Cloud object key for a playlist id — must match native exactly. +String playlistCloudKey(String id) => '$kPlaylistPrefix$id.json'; + +/// Exact upload bytes native reads: `Playlist.toJson()` → UTF-8 JSON. +Uint8List playlistUploadBytes(Playlist p) => + Uint8List.fromList(utf8.encode(jsonEncode(p.toJson()))); + +/// A new playlist matching native `createPlaylist` (caller supplies the +/// uuid id; createdAt == updatedAt == [now]). +Playlist buildNewPlaylist({ + required String id, + required String name, + required List tracks, + required DateTime now, +}) => + Playlist( + id: id, + name: name, + tracks: tracks, + createdAt: now, + updatedAt: now, + ); + +/// Append [track] to [p] (dedup by path, like native `Playlist.addTrack`) and +/// bump `updatedAt` to [now]. Returns false (no change) if the path is already +/// present. [now] is injected rather than `DateTime.now()` for testability. +bool appendTrack(Playlist p, AudioTrack track, DateTime now) { + if (p.tracks.contains(track)) return false; // AudioTrack == is by path + p.tracks.add(track); + p.updatedAt = now; + return true; +} diff --git a/lib/web/widgets/web_audio_player.dart b/lib/web/widgets/web_audio_player.dart new file mode 100644 index 0000000..15f4006 --- /dev/null +++ b/lib/web/widgets/web_audio_player.dart @@ -0,0 +1,437 @@ +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +import 'package:fula_files/core/models/playlist.dart'; +import 'package:fula_files/web/services/web_audio_controller.dart'; +import 'package:fula_files/web/services/web_audio_queue.dart'; +import 'package:fula_files/web/services/web_features.dart'; +import 'package:fula_files/web/services/web_playlist_service.dart'; +import 'package:fula_files/web/services/web_save.dart'; + +/// Full-screen web audio player (#21), mirroring the native +/// `audio_player_screen.dart`: now-playing, seek slider, play/pause, skip +/// prev/next, rewind/forward 10s, repeat (off/one/all), shuffle, a tappable +/// queue, add-to-playlist and download. Shown via `Dialog.fullscreen` (like +/// the image/text previews); owns a [WebAudioController] for its lifetime. +class WebAudioPlayer extends StatefulWidget { + final List queue; + final int startIndex; + const WebAudioPlayer({ + super.key, + required this.queue, + required this.startIndex, + }); + + @override + State createState() => _WebAudioPlayerState(); +} + +class _WebAudioPlayerState extends State { + final WebAudioController _c = WebAudioController(); + + @override + void initState() { + super.initState(); + _c.playQueue(widget.queue, widget.startIndex); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + void _snack(String m) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(m))); + } + + Future _download() async { + final t = _c.current; + if (t == null) return; + try { + final bytes = await t.download(); + saveBytesAsDownload(t.name, bytes, mimeType: t.mime); + } catch (e) { + _snack('Download failed: $e'); + } + } + + Future _addToPlaylist() async { + final t = _c.current; + if (t == null) return; + final at = AudioTrack( + path: t.cloudKey, + name: t.name, + durationMs: _c.player.duration?.inMilliseconds ?? 0, + ); + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => _AddToPlaylistSheet(track: at), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(LucideIcons.chevronDown), + tooltip: 'Close', + onPressed: () => Navigator.of(context).pop(), + ), + title: AnimatedBuilder( + animation: _c, + builder: (_, __) => Text( + _c.current?.name ?? 'Audio', + overflow: TextOverflow.ellipsis, + ), + ), + actions: [ + IconButton( + icon: const Icon(LucideIcons.listPlus), + tooltip: 'Add to playlist', + onPressed: _addToPlaylist, + ), + IconButton( + icon: const Icon(LucideIcons.download), + tooltip: 'Download', + onPressed: _download, + ), + ], + ), + body: AnimatedBuilder( + animation: _c, + builder: (context, _) => Column( + children: [ + Expanded(child: _nowPlaying(theme)), + _progressBar(theme), + _mainControls(theme), + _secondaryControls(theme), + const Divider(height: 1), + Expanded(child: _queueView(theme)), + ], + ), + ), + ); + } + + Widget _nowPlaying(ThemeData theme) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 180, + height: 180, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Icon(LucideIcons.music, + size: 72, color: theme.colorScheme.onPrimaryContainer), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + _c.current?.name ?? '', + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + } + + Widget _progressBar(ThemeData theme) { + return StreamBuilder( + stream: _c.player.durationStream, + builder: (_, durSnap) { + final total = durSnap.data ?? Duration.zero; + return StreamBuilder( + stream: _c.player.positionStream, + builder: (_, posSnap) { + var pos = posSnap.data ?? Duration.zero; + if (pos > total) pos = total; + final maxMs = total.inMilliseconds.toDouble(); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + Slider( + value: pos.inMilliseconds + .clamp(0, maxMs.toInt()) + .toDouble(), + max: maxMs <= 0 ? 1 : maxMs, + onChanged: maxMs <= 0 + ? null + : (v) => + _c.seek(Duration(milliseconds: v.round())), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(fmtDuration(pos), + style: theme.textTheme.bodySmall), + Text(fmtDuration(total), + style: theme.textTheme.bodySmall), + ], + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + Widget _mainControls(ThemeData theme) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(LucideIcons.skipBack), + tooltip: 'Previous', + onPressed: _c.previous, + ), + IconButton( + icon: const Icon(LucideIcons.rewind), + tooltip: 'Back 10s', + onPressed: _c.rewind, + ), + const SizedBox(width: 8), + StreamBuilder( + stream: _c.player.playerStateStream, + builder: (_, snap) { + final playing = snap.data?.playing ?? false; + return IconButton.filled( + iconSize: 40, + icon: Icon(playing ? LucideIcons.pause : LucideIcons.play), + tooltip: playing ? 'Pause' : 'Play', + onPressed: _c.playPause, + ); + }, + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(LucideIcons.fastForward), + tooltip: 'Forward 10s', + onPressed: _c.forward, + ), + IconButton( + icon: const Icon(LucideIcons.skipForward), + tooltip: 'Next', + onPressed: _c.next, + ), + ], + ); + } + + Widget _secondaryControls(ThemeData theme) { + final repeat = _c.repeatMode; + final repeatActive = repeat != WebRepeatMode.off; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: Icon(LucideIcons.shuffle, + color: _c.shuffle ? theme.colorScheme.primary : null), + tooltip: _c.shuffle ? 'Shuffle on' : 'Shuffle off', + onPressed: _c.toggleShuffle, + ), + const SizedBox(width: 32), + IconButton( + icon: Icon( + repeat == WebRepeatMode.one + ? LucideIcons.repeat1 + : LucideIcons.repeat, + color: repeatActive ? theme.colorScheme.primary : null, + ), + tooltip: switch (repeat) { + WebRepeatMode.off => 'Repeat off', + WebRepeatMode.one => 'Repeat one', + WebRepeatMode.all => 'Repeat all', + }, + onPressed: _c.cycleRepeat, + ), + ], + ), + ); + } + + Widget _queueView(ThemeData theme) { + if (_c.queue.isEmpty) return const SizedBox.shrink(); + return ListView.builder( + itemCount: _c.queue.length, + itemBuilder: (context, i) { + final t = _c.queue[i]; + final isCurrent = i == _c.index; + return ListTile( + dense: true, + selected: isCurrent, + leading: Icon( + isCurrent ? LucideIcons.volume2 : LucideIcons.music, + size: 18, + color: isCurrent ? theme.colorScheme.primary : null, + ), + title: Text(t.name, + maxLines: 1, overflow: TextOverflow.ellipsis), + onTap: () => _c.jumpTo(i), + ); + }, + ); + } +} + +/// Bottom sheet: create a new playlist or add the track to an existing one. +/// Writes go through [WebPlaylistService] (cloud, native-compatible). +class _AddToPlaylistSheet extends StatefulWidget { + final AudioTrack track; + const _AddToPlaylistSheet({required this.track}); + + @override + State<_AddToPlaylistSheet> createState() => _AddToPlaylistSheetState(); +} + +class _AddToPlaylistSheetState extends State<_AddToPlaylistSheet> { + late Future> _future; + bool _busy = false; + + @override + void initState() { + super.initState(); + _future = WebFeatures.loadPlaylists(); + } + + void _snack(String m) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(m))); + } + + Future _createNew() async { + final name = await showDialog( + context: context, + builder: (ctx) { + final controller = TextEditingController(); + return AlertDialog( + title: const Text('Create playlist'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(hintText: 'Playlist name'), + onSubmitted: (_) => + Navigator.pop(ctx, controller.text.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(ctx, controller.text.trim()), + child: const Text('Create'), + ), + ], + ); + }, + ); + if (name == null || name.isEmpty) return; + setState(() => _busy = true); + try { + await WebPlaylistService.instance + .createPlaylist(name, tracks: [widget.track]); + if (!mounted) return; + Navigator.pop(context); + _snack('Created "$name"'); + } catch (e) { + setState(() => _busy = false); + _snack('Could not create playlist: $e'); + } + } + + Future _addToExisting(Playlist p) async { + setState(() => _busy = true); + try { + final added = await WebPlaylistService.instance + .addTrackToPlaylist(p.id, widget.track); + if (!mounted) return; + Navigator.pop(context); + _snack(added ? 'Added to "${p.name}"' : 'Already in "${p.name}"'); + } catch (e) { + setState(() => _busy = false); + _snack('Could not add to playlist: $e'); + } + } + + @override + Widget build(BuildContext context) { + if (_busy) { + return const SizedBox( + height: 160, + child: Center(child: CircularProgressIndicator()), + ); + } + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.plus), + title: const Text('Create new playlist'), + onTap: _createNew, + ), + const Divider(height: 1), + Flexible( + child: FutureBuilder>( + future: _future, + builder: (_, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center(child: CircularProgressIndicator()), + ); + } + final playlists = snap.data ?? const []; + if (playlists.isEmpty) { + return const Padding( + padding: EdgeInsets.all(24), + child: Text('No playlists yet'), + ); + } + return ListView.builder( + shrinkWrap: true, + itemCount: playlists.length, + itemBuilder: (_, i) { + final p = playlists[i]; + return ListTile( + leading: const Icon(LucideIcons.listMusic), + title: Text(p.name, + maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text('${p.tracks.length} tracks'), + onTap: () => _addToExisting(p), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/test/unit/web/web_audio_queue_test.dart b/test/unit/web/web_audio_queue_test.dart new file mode 100644 index 0000000..cea607c --- /dev/null +++ b/test/unit/web/web_audio_queue_test.dart @@ -0,0 +1,78 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fula_files/web/services/web_audio_queue.dart'; + +/// Unit tests for the pure web-audio queue / repeat / shuffle logic (#21). +/// The just_audio + blob-URL playback glue (web_audio_controller.dart) is +/// browser-only and verified live; these transitions are the VM-safe core. +void main() { + group('nextRepeatMode', () { + test('cycles off -> one -> all -> off', () { + expect(nextRepeatMode(WebRepeatMode.off), WebRepeatMode.one); + expect(nextRepeatMode(WebRepeatMode.one), WebRepeatMode.all); + expect(nextRepeatMode(WebRepeatMode.all), WebRepeatMode.off); + }); + }); + + group('nextIndexOnComplete', () { + test('repeat one replays current', () { + expect(nextIndexOnComplete(2, 5, WebRepeatMode.one), 2); + }); + test('repeat all wraps at the end', () { + expect(nextIndexOnComplete(4, 5, WebRepeatMode.all), 0); + expect(nextIndexOnComplete(1, 5, WebRepeatMode.all), 2); + }); + test('repeat off advances then stops at the end', () { + expect(nextIndexOnComplete(1, 5, WebRepeatMode.off), 2); + expect(nextIndexOnComplete(4, 5, WebRepeatMode.off), isNull); + }); + test('empty queue stops', () { + expect(nextIndexOnComplete(0, 0, WebRepeatMode.all), isNull); + }); + }); + + group('nextIndexManual', () { + test('advances mid-list regardless of repeat mode', () { + expect(nextIndexManual(1, 5, WebRepeatMode.off), 2); + expect(nextIndexManual(1, 5, WebRepeatMode.one), 2); + }); + test('at the end wraps only under repeat-all', () { + expect(nextIndexManual(4, 5, WebRepeatMode.all), 0); + expect(nextIndexManual(4, 5, WebRepeatMode.off), isNull); + expect(nextIndexManual(4, 5, WebRepeatMode.one), isNull); + }); + }); + + group('prevIndexManual', () { + test('steps back mid-list', () { + expect(prevIndexManual(3, 5, WebRepeatMode.off), 2); + }); + test('at the start wraps only under repeat-all', () { + expect(prevIndexManual(0, 5, WebRepeatMode.all), 4); + expect(prevIndexManual(0, 5, WebRepeatMode.off), 0); + }); + }); + + group('buildShuffleOrder', () { + test('is a permutation with the current index first', () { + final order = buildShuffleOrder(6, 3, Random(42)); + expect(order.first, 3); + expect(order.toSet(), {0, 1, 2, 3, 4, 5}); + expect(order.length, 6); + }); + test('tolerates a current index out of range', () { + final order = buildShuffleOrder(4, -1, Random(1)); + expect(order.toSet(), {0, 1, 2, 3}); + }); + }); + + group('fmtDuration', () { + test('formats m:ss with a zero-padded seconds field', () { + expect(fmtDuration(Duration.zero), '0:00'); + expect(fmtDuration(const Duration(seconds: 65)), '1:05'); + expect(fmtDuration(const Duration(minutes: 12, seconds: 9)), '12:09'); + }); + }); +} diff --git a/test/unit/web/web_playlist_write_logic_test.dart b/test/unit/web/web_playlist_write_logic_test.dart new file mode 100644 index 0000000..40636d1 --- /dev/null +++ b/test/unit/web/web_playlist_write_logic_test.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fula_files/core/models/playlist.dart'; +import 'package:fula_files/web/services/web_playlist_write_logic.dart'; + +/// Unit tests for the pure web playlist-WRITE transforms (#21). The cloud IO +/// glue (encrypt + upload) is in web_playlist_service.dart; here we guard the +/// part that must stay byte-compatible with what native reads/writes. +void main() { + AudioTrack track(String path, {String? name}) => + AudioTrack(path: path, name: name ?? path); + + test('playlistCloudKey matches the native key scheme', () { + expect(playlistCloudKey('abc123'), 'user-playlists/abc123.json'); + }); + + test('buildNewPlaylist sets id/name/tracks and equal timestamps', () { + final now = DateTime.utc(2026, 6, 14, 12); + final p = buildNewPlaylist( + id: 'p1', name: 'Faves', tracks: [track('/a.mp3')], now: now); + expect(p.id, 'p1'); + expect(p.name, 'Faves'); + expect(p.tracks.single.path, '/a.mp3'); + expect(p.createdAt, now); + expect(p.updatedAt, now); + }); + + test('upload bytes round-trip through Playlist.fromJson (format fidelity)', + () { + final now = DateTime.utc(2026, 6, 14, 12); + final p = buildNewPlaylist( + id: 'p1', + name: 'Faves', + tracks: [track('/a.mp3', name: 'A'), track('/b.mp3', name: 'B')], + now: now, + ); + final restored = Playlist.fromJson( + jsonDecode(utf8.decode(playlistUploadBytes(p))) + as Map); + expect(restored.id, 'p1'); + expect(restored.name, 'Faves'); + expect(restored.tracks.map((t) => t.path), ['/a.mp3', '/b.mp3']); + expect(restored.createdAt, now); + expect(restored.updatedAt, now); + }); + + group('appendTrack', () { + test('appends a new track and bumps updatedAt', () { + final created = DateTime.utc(2026, 6, 14, 12); + final later = DateTime.utc(2026, 6, 14, 13); + final p = buildNewPlaylist( + id: 'p1', name: 'P', tracks: [track('/a.mp3')], now: created); + final added = appendTrack(p, track('/b.mp3'), later); + expect(added, isTrue); + expect(p.tracks.map((t) => t.path), ['/a.mp3', '/b.mp3']); + expect(p.updatedAt, later); + }); + + test('dedups by path (no change, no timestamp bump)', () { + final created = DateTime.utc(2026, 6, 14, 12); + final later = DateTime.utc(2026, 6, 14, 13); + final p = buildNewPlaylist( + id: 'p1', name: 'P', tracks: [track('/a.mp3')], now: created); + final added = appendTrack(p, track('/a.mp3'), later); + expect(added, isFalse); + expect(p.tracks.length, 1); + expect(p.updatedAt, created); // unchanged + }); + }); +}