Add audio player functionality to file explorer and integrate just_audio package

This commit is contained in:
Leon Bösche
2026-01-16 15:33:21 +01:00
parent b27cc5eaf0
commit 2cdc55ba2f
5 changed files with 435 additions and 166 deletions

View File

@@ -1,3 +1,4 @@
import 'package:b0esche_cloud/widgets/audio_player_bar.dart';
import 'package:dio/dio.dart';
import 'dart:ui';
import 'dart:js_interop';
@@ -35,7 +36,8 @@ class FileExplorer extends StatefulWidget {
State<FileExplorer> createState() => _FileExplorerState();
}
class _FileExplorerState extends State<FileExplorer> {
class _FileExplorerState extends State<FileExplorer>
with SingleTickerProviderStateMixin {
String _getFileTypeLabel(FileItem file) {
if (file.type == FileType.folder) return 'Folder';
final name = file.name.toLowerCase();
@@ -111,6 +113,11 @@ class _FileExplorerState extends State<FileExplorer> {
}
String? _selectedFilePath;
String? _audioFileName;
String? _audioFileUrl;
bool _showAudioBar = false;
late AnimationController _audioBarController;
late Animation<Offset> _audioBarOffset;
bool _isSearching = false;
bool _showField = false;
final TextEditingController _searchController = TextEditingController();
@@ -138,12 +145,29 @@ class _FileExplorerState extends State<FileExplorer> {
@override
void dispose() {
_audioBarController.dispose();
_searchController.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_audioBarController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
);
_audioBarOffset =
Tween<Offset>(begin: const Offset(0, -1), end: Offset.zero).animate(
CurvedAnimation(
parent: _audioBarController,
curve: Curves.easeOutCubic,
),
);
context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: widget.orgId, path: '/'),
);
context.read<PermissionBloc>().add(LoadPermissions(widget.orgId));
super.initState();
context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: widget.orgId, path: '/'),
@@ -863,178 +887,214 @@ class _FileExplorerState extends State<FileExplorer> {
Widget _buildTitle() {
const double titleWidth = 72.0;
return SizedBox(
width: double.infinity,
height: 50,
child: Stack(
alignment: Alignment.centerLeft,
children: [
const Positioned(
left: 0,
child: Text(
'/Drive',
style: TextStyle(
fontSize: 24,
color: AppTheme.primaryText,
fontWeight: FontWeight.bold,
),
),
),
AnimatedPositioned(
left: _isSearching ? titleWidth + 250.0 : titleWidth,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: IconButton(
icon: Icon(
_isSearching ? Icons.close : Icons.search,
color: AppTheme.accentColor,
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: double.infinity,
height: 50,
child: Stack(
alignment: Alignment.centerLeft,
children: [
const Positioned(
left: 0,
child: Text(
'/Drive',
style: TextStyle(
fontSize: 24,
color: AppTheme.primaryText,
fontWeight: FontWeight.bold,
),
),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
onPressed: () {
if (_isSearching) {
setState(() {
_showField = false;
_isSearching = false;
_searchController.clear();
_searchQuery = '';
context.read<FileBrowserBloc>().add(ApplyFilter(''));
});
} else {
setState(() {
_isSearching = true;
});
Future.delayed(const Duration(milliseconds: 150), () {
setState(() {
_showField = true;
});
});
}
},
),
),
),
Positioned(
right: 0,
top: 0,
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: BlocBuilder<FileBrowserBloc, FileBrowserState>(
builder: (context, state) {
String currentSort = 'name';
bool isAscending = true;
if (state is DirectoryLoaded) {
currentSort = state.sortBy;
isAscending = state.isAscending;
}
return Row(
children: [
PopupMenuButton<String>(
color: AppTheme.accentColor.withAlpha(220),
position: PopupMenuPosition.under,
offset: const Offset(48, 8),
itemBuilder: (BuildContext context) => const [
PopupMenuItem(value: 'name', child: Text('Name')),
PopupMenuItem(value: 'date', child: Text('Date')),
PopupMenuItem(value: 'size', child: Text('Size')),
PopupMenuItem(value: 'type', child: Text('Type')),
AnimatedPositioned(
left: _isSearching ? titleWidth + 250.0 : titleWidth,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: IconButton(
icon: Icon(
_isSearching ? Icons.close : Icons.search,
color: AppTheme.accentColor,
),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
onPressed: () {
if (_isSearching) {
setState(() {
_showField = false;
_isSearching = false;
_searchController.clear();
_searchQuery = '';
context.read<FileBrowserBloc>().add(ApplyFilter(''));
});
} else {
setState(() {
_isSearching = true;
});
Future.delayed(const Duration(milliseconds: 150), () {
setState(() {
_showField = true;
});
});
}
},
),
),
),
Positioned(
right: 0,
top: 0,
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: BlocBuilder<FileBrowserBloc, FileBrowserState>(
builder: (context, state) {
String currentSort = 'name';
bool isAscending = true;
if (state is DirectoryLoaded) {
currentSort = state.sortBy;
isAscending = state.isAscending;
}
return Row(
children: [
PopupMenuButton<String>(
color: AppTheme.accentColor.withAlpha(220),
position: PopupMenuPosition.under,
offset: const Offset(48, 8),
itemBuilder: (BuildContext context) => const [
PopupMenuItem(value: 'name', child: Text('Name')),
PopupMenuItem(value: 'date', child: Text('Date')),
PopupMenuItem(value: 'size', child: Text('Size')),
PopupMenuItem(value: 'type', child: Text('Type')),
],
onSelected: (value) {
context.read<FileBrowserBloc>().add(
ApplySort(value, isAscending: isAscending),
);
},
child: Row(
children: [
Icon(
Icons.arrow_drop_down,
color: AppTheme.primaryText,
),
const SizedBox(width: 4),
Text(
currentSort == 'name'
? 'Name'
: currentSort == 'date'
? 'Date'
: currentSort == 'size'
? 'Size'
: 'Type',
style: const TextStyle(
color: AppTheme.primaryText,
fontSize: 14,
),
),
],
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
isAscending
? Icons.arrow_upward
: Icons.arrow_downward,
color: AppTheme.accentColor,
),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
onPressed: () {
context.read<FileBrowserBloc>().add(
ApplySort(
currentSort,
isAscending: !isAscending,
),
);
},
),
],
onSelected: (value) {
context.read<FileBrowserBloc>().add(
ApplySort(value, isAscending: isAscending),
);
},
child: Row(
children: [
Icon(
Icons.arrow_drop_down,
color: AppTheme.primaryText,
),
const SizedBox(width: 4),
Text(
currentSort == 'name'
? 'Name'
: currentSort == 'date'
? 'Date'
: currentSort == 'size'
? 'Size'
: 'Type',
style: const TextStyle(
color: AppTheme.primaryText,
fontSize: 14,
),
),
],
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
isAscending
? Icons.arrow_upward
: Icons.arrow_downward,
color: AppTheme.accentColor,
),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
onPressed: () {
context.read<FileBrowserBloc>().add(
ApplySort(currentSort, isAscending: !isAscending),
);
},
),
],
);
},
);
},
),
),
),
),
),
if (_showField)
AnimatedPositioned(
left: titleWidth,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: SizedBox(
width: 250,
child: TextField(
controller: _searchController,
autofocus: true,
style: const TextStyle(color: AppTheme.primaryText),
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'Search Files...',
hintStyle: const TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(
color: AppTheme.secondaryText.withValues(alpha: 0.5),
if (_showField)
AnimatedPositioned(
left: titleWidth,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: SizedBox(
width: 250,
child: TextField(
controller: _searchController,
autofocus: true,
style: const TextStyle(color: AppTheme.primaryText),
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'Search Files...',
hintStyle: const TextStyle(
color: AppTheme.secondaryText,
),
contentPadding: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide(
color: AppTheme.secondaryText.withValues(
alpha: 0.5,
),
),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(24)),
borderSide: BorderSide(color: AppTheme.accentColor),
),
),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(24)),
borderSide: BorderSide(color: AppTheme.accentColor),
onChanged: (value) {
_searchQuery = value;
context.read<FileBrowserBloc>().add(
ApplyFilter(_searchQuery),
);
},
),
),
onChanged: (value) {
_searchQuery = value;
context.read<FileBrowserBloc>().add(
ApplyFilter(_searchQuery),
);
},
),
),
),
],
),
],
),
),
// Animated audio bar
AnimatedBuilder(
animation: _audioBarController,
builder: (context, child) {
return (_showAudioBar &&
_audioFileName != null &&
_audioFileUrl != null)
? SlideTransition(
position: _audioBarOffset,
child: Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: AudioPlayerBar(
fileName: _audioFileName!,
fileUrl: _audioFileUrl!,
onClose: () {
_audioBarController.reverse();
setState(() => _showAudioBar = false);
},
),
),
)
: const SizedBox.shrink();
},
),
],
);
}
@@ -1064,6 +1124,20 @@ class _FileExplorerState extends State<FileExplorer> {
(ext) => file.name.toLowerCase().endsWith(ext),
);
final ext = p.extension(file.name).toLowerCase();
const audioExts = [
'.mp3',
'.wav',
'.flac',
'.ogg',
'.aac',
'.m4a',
'.opus',
'.wma',
'.alac',
'.aiff',
'.amr',
];
return MouseRegion(
onEnter: (_) => setState(() => _hovered[file.path] = true),
onExit: (_) => setState(() => _hovered[file.path] = false),
@@ -1075,7 +1149,6 @@ class _FileExplorerState extends State<FileExplorer> {
if (file.type == FileType.folder) {
context.read<FileBrowserBloc>().add(NavigateToFolder(file.path));
} else if (isVideo) {
// Open video files in video viewer - use viewer session for authenticated URL
if (file.id == null || file.id!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error: File ID is missing')),
@@ -1095,6 +1168,25 @@ class _FileExplorerState extends State<FileExplorer> {
);
}
}
} else if (audioExts.contains(ext)) {
if (file.id == null || file.id!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error: File ID is missing')),
);
return;
}
final fileService = getIt<FileService>();
final url = await fileService.getFileUrl(
orgId: widget.orgId,
fileId: file.id!,
fileName: file.name,
);
setState(() {
_audioFileName = file.name;
_audioFileUrl = url;
_showAudioBar = true;
});
_audioBarController.forward();
} else {
if (file.id == null || file.id!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
class AudioPlayerBar extends StatefulWidget {
final String fileName;
final String fileUrl;
final VoidCallback? onClose;
const AudioPlayerBar({
super.key,
required this.fileName,
required this.fileUrl,
this.onClose,
});
@override
State<AudioPlayerBar> createState() => _AudioPlayerBarState();
}
class _AudioPlayerBarState extends State<AudioPlayerBar> {
late AudioPlayer _audioPlayer;
Duration _duration = Duration.zero;
Duration _position = Duration.zero;
bool _isPlaying = false;
bool _isLoading = true;
@override
void initState() {
super.initState();
_audioPlayer = AudioPlayer();
_initAudio();
}
Future<void> _initAudio() async {
try {
await _audioPlayer.setUrl(widget.fileUrl);
setState(() {
_duration = _audioPlayer.duration ?? Duration.zero;
_isLoading = false;
});
_audioPlayer.positionStream.listen((pos) {
setState(() {
_position = pos;
});
});
_audioPlayer.playerStateStream.listen((state) {
setState(() {
_isPlaying = state.playing;
});
});
} catch (e) {
setState(() {
_isLoading = false;
});
// Optionally show error
}
}
@override
void dispose() {
_audioPlayer.dispose();
super.dispose();
}
String _formatDuration(Duration d) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
final minutes = twoDigits(d.inMinutes.remainder(60));
final seconds = twoDigits(d.inSeconds.remainder(60));
return '$minutes:$seconds';
}
@override
Widget build(BuildContext context) {
return Material(
elevation: 8,
color: Theme.of(context).colorScheme.surface,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: 64,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
IconButton(
icon: Icon(_isPlaying ? Icons.pause : Icons.play_arrow),
onPressed: _isLoading
? null
: () {
if (_isPlaying) {
_audioPlayer.pause();
} else {
_audioPlayer.play();
}
},
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.fileName,
style: Theme.of(context).textTheme.bodyLarge,
overflow: TextOverflow.ellipsis,
),
Slider(
min: 0,
max: _duration.inMilliseconds.toDouble(),
value: _position.inMilliseconds
.clamp(0, _duration.inMilliseconds)
.toDouble(),
onChanged: _isLoading
? null
: (value) {
_audioPlayer.seek(
Duration(milliseconds: value.toInt()),
);
},
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
'${_formatDuration(_position)} / ${_formatDuration(_duration)}',
style: Theme.of(context).textTheme.bodySmall,
),
),
if (widget.onClose != null)
IconButton(
icon: const Icon(Icons.close),
onPressed: widget.onClose,
),
],
),
),
);
}
}

View File

@@ -5,12 +5,14 @@
import FlutterMacOS
import Foundation
import audio_session
import connectivity_plus
import desktop_drop
import device_info_plus
import file_picker
import flutter_secure_storage_darwin
import irondash_engine_context
import just_audio
import shared_preferences_foundation
import sqflite_darwin
import super_native_extensions
@@ -19,12 +21,14 @@ import url_launcher_macos
import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin"))

View File

@@ -41,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
audio_session:
dependency: transitive
description:
name: audio_session
sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
bloc:
dependency: "direct main"
description:
@@ -696,6 +704,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.8.0"
just_audio:
dependency: "direct main"
description:
name: just_audio
sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908"
url: "https://pub.dev"
source: hosted
version: "0.10.5"
just_audio_platform_interface:
dependency: transitive
description:
name: just_audio_platform_interface
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
just_audio_web:
dependency: "direct main"
description:
name: just_audio_web
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
url: "https://pub.dev"
source: hosted
version: "0.4.16"
leak_tracker:
dependency: transitive
description:

View File

@@ -63,6 +63,8 @@ dependencies:
# Video Playback
video_player: ^2.8.2
syncfusion_flutter_core: ^31.2.18
just_audio_web: ^0.4.16
just_audio: ^0.10.5
dev_dependencies:
flutter_test: