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 'package:dio/dio.dart';
import 'dart:ui'; import 'dart:ui';
import 'dart:js_interop'; import 'dart:js_interop';
@@ -35,7 +36,8 @@ class FileExplorer extends StatefulWidget {
State<FileExplorer> createState() => _FileExplorerState(); State<FileExplorer> createState() => _FileExplorerState();
} }
class _FileExplorerState extends State<FileExplorer> { class _FileExplorerState extends State<FileExplorer>
with SingleTickerProviderStateMixin {
String _getFileTypeLabel(FileItem file) { String _getFileTypeLabel(FileItem file) {
if (file.type == FileType.folder) return 'Folder'; if (file.type == FileType.folder) return 'Folder';
final name = file.name.toLowerCase(); final name = file.name.toLowerCase();
@@ -111,6 +113,11 @@ class _FileExplorerState extends State<FileExplorer> {
} }
String? _selectedFilePath; String? _selectedFilePath;
String? _audioFileName;
String? _audioFileUrl;
bool _showAudioBar = false;
late AnimationController _audioBarController;
late Animation<Offset> _audioBarOffset;
bool _isSearching = false; bool _isSearching = false;
bool _showField = false; bool _showField = false;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
@@ -138,12 +145,29 @@ class _FileExplorerState extends State<FileExplorer> {
@override @override
void dispose() { void dispose() {
_audioBarController.dispose();
_searchController.dispose(); _searchController.dispose();
super.dispose(); super.dispose();
} }
@override @override
void initState() { 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(); super.initState();
context.read<FileBrowserBloc>().add( context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: widget.orgId, path: '/'), LoadDirectory(orgId: widget.orgId, path: '/'),
@@ -863,178 +887,214 @@ class _FileExplorerState extends State<FileExplorer> {
Widget _buildTitle() { Widget _buildTitle() {
const double titleWidth = 72.0; const double titleWidth = 72.0;
return SizedBox( return Column(
width: double.infinity, mainAxisSize: MainAxisSize.min,
height: 50, children: [
child: Stack( SizedBox(
alignment: Alignment.centerLeft, width: double.infinity,
children: [ height: 50,
const Positioned( child: Stack(
left: 0, alignment: Alignment.centerLeft,
child: Text( children: [
'/Drive', const Positioned(
style: TextStyle( left: 0,
fontSize: 24, child: Text(
color: AppTheme.primaryText, '/Drive',
fontWeight: FontWeight.bold, 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,
), ),
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;
});
});
}
},
), ),
), AnimatedPositioned(
), left: _isSearching ? titleWidth + 250.0 : titleWidth,
Positioned( duration: const Duration(milliseconds: 250),
right: 0, curve: Curves.easeInOut,
top: 0, child: Padding(
child: Padding( padding: const EdgeInsets.only(top: 2),
padding: const EdgeInsets.only(top: 2), child: IconButton(
child: BlocBuilder<FileBrowserBloc, FileBrowserState>( icon: Icon(
builder: (context, state) { _isSearching ? Icons.close : Icons.search,
String currentSort = 'name'; color: AppTheme.accentColor,
bool isAscending = true; ),
if (state is DirectoryLoaded) { splashColor: Colors.transparent,
currentSort = state.sortBy; highlightColor: Colors.transparent,
isAscending = state.isAscending; onPressed: () {
} if (_isSearching) {
return Row( setState(() {
children: [ _showField = false;
PopupMenuButton<String>( _isSearching = false;
color: AppTheme.accentColor.withAlpha(220), _searchController.clear();
position: PopupMenuPosition.under, _searchQuery = '';
offset: const Offset(48, 8), context.read<FileBrowserBloc>().add(ApplyFilter(''));
itemBuilder: (BuildContext context) => const [ });
PopupMenuItem(value: 'name', child: Text('Name')), } else {
PopupMenuItem(value: 'date', child: Text('Date')), setState(() {
PopupMenuItem(value: 'size', child: Text('Size')), _isSearching = true;
PopupMenuItem(value: 'type', child: Text('Type')), });
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(
if (_showField) left: titleWidth,
AnimatedPositioned( duration: const Duration(milliseconds: 300),
left: titleWidth, curve: Curves.easeInOut,
duration: const Duration(milliseconds: 300), child: SizedBox(
curve: Curves.easeInOut, width: 250,
child: SizedBox( child: TextField(
width: 250, controller: _searchController,
child: TextField( autofocus: true,
controller: _searchController, style: const TextStyle(color: AppTheme.primaryText),
autofocus: true, cursorColor: AppTheme.accentColor,
style: const TextStyle(color: AppTheme.primaryText), decoration: InputDecoration(
cursorColor: AppTheme.accentColor, hintText: 'Search Files...',
decoration: InputDecoration( hintStyle: const TextStyle(
hintText: 'Search Files...', color: AppTheme.secondaryText,
hintStyle: const TextStyle(color: AppTheme.secondaryText), ),
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
vertical: 2, vertical: 2,
horizontal: 12, horizontal: 12,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
borderSide: BorderSide( borderSide: BorderSide(
color: AppTheme.secondaryText.withValues(alpha: 0.5), color: AppTheme.secondaryText.withValues(
alpha: 0.5,
),
),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(24)),
borderSide: BorderSide(color: AppTheme.accentColor),
),
), ),
), onChanged: (value) {
focusedBorder: const OutlineInputBorder( _searchQuery = value;
borderRadius: BorderRadius.all(Radius.circular(24)), context.read<FileBrowserBloc>().add(
borderSide: BorderSide(color: AppTheme.accentColor), 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), (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( return MouseRegion(
onEnter: (_) => setState(() => _hovered[file.path] = true), onEnter: (_) => setState(() => _hovered[file.path] = true),
onExit: (_) => setState(() => _hovered[file.path] = false), onExit: (_) => setState(() => _hovered[file.path] = false),
@@ -1075,7 +1149,6 @@ class _FileExplorerState extends State<FileExplorer> {
if (file.type == FileType.folder) { if (file.type == FileType.folder) {
context.read<FileBrowserBloc>().add(NavigateToFolder(file.path)); context.read<FileBrowserBloc>().add(NavigateToFolder(file.path));
} else if (isVideo) { } else if (isVideo) {
// Open video files in video viewer - use viewer session for authenticated URL
if (file.id == null || file.id!.isEmpty) { if (file.id == null || file.id!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error: File ID is missing')), 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 { } else {
if (file.id == null || file.id!.isEmpty) { if (file.id == null || file.id!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( 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 FlutterMacOS
import Foundation import Foundation
import audio_session
import connectivity_plus import connectivity_plus
import desktop_drop import desktop_drop
import device_info_plus import device_info_plus
import file_picker import file_picker
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import irondash_engine_context import irondash_engine_context
import just_audio
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
import super_native_extensions import super_native_extensions
@@ -19,12 +21,14 @@ import url_launcher_macos
import video_player_avfoundation import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin"))

View File

@@ -41,6 +41,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" 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: bloc:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -696,6 +704,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.8.0" 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: leak_tracker:
dependency: transitive dependency: transitive
description: description:

View File

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