Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions lib/web/screens/web_bucket_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -795,6 +797,11 @@ class _WebBucketScreenState extends State<WebBucketScreen> {
/// codec support depends on the browser; the dialog offers Download
/// as the fallback.
Future<void> _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;
Expand All @@ -816,6 +823,45 @@ class _WebBucketScreenState extends State<WebBucketScreen> {
}
}

/// 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<void> _openAudioPlayer(FulaObject tapped) async {
final audio = (_objects ?? const <FulaObject>[]).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<void>(
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)
Expand Down
48 changes: 27 additions & 21 deletions lib/web/screens/web_playlists_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -164,27 +165,32 @@ class _WebPlaylistDetailScreenState extends State<WebPlaylistDetailScreen> {
}
}

Future<void> _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<void>(
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<void>(
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
Expand Down
202 changes: 202 additions & 0 deletions lib/web/services/web_audio_controller.dart
Original file line number Diff line number Diff line change
@@ -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<Uint8List> 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<WebAudioTrack> _queue = const [];
List<WebAudioTrack> _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<ProcessingState>? _procSub;

WebAudioController() {
_procSub = _player.processingStateStream.listen((s) {
if (s == ProcessingState.completed) _onComplete();
});
}

List<WebAudioTrack> 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<void> playQueue(List<WebAudioTrack> 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<void> jumpTo(int i) => _loadAndPlay(i);

Future<void> _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<void> playPause() =>
_player.playing ? _player.pause() : _player.play();

Future<void> next() async {
final n = nextIndexManual(_index, _queue.length, _repeat);
if (n != null) await _loadAndPlay(n);
}

Future<void> 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<void> seek(Duration d) => _player.seek(d);

Future<void> rewind() async {
final p = _player.position - const Duration(seconds: 10);
await _player.seek(p < Duration.zero ? Duration.zero : p);
}

Future<void> 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<WebAudioTrack> 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();
}
}
Loading