diff --git a/b0esche_cloud/lib/pages/file_explorer.dart b/b0esche_cloud/lib/pages/file_explorer.dart index c672079..1321481 100644 --- a/b0esche_cloud/lib/pages/file_explorer.dart +++ b/b0esche_cloud/lib/pages/file_explorer.dart @@ -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 createState() => _FileExplorerState(); } -class _FileExplorerState extends State { +class _FileExplorerState extends State + 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 { } String? _selectedFilePath; + String? _audioFileName; + String? _audioFileUrl; + bool _showAudioBar = false; + late AnimationController _audioBarController; + late Animation _audioBarOffset; bool _isSearching = false; bool _showField = false; final TextEditingController _searchController = TextEditingController(); @@ -138,12 +145,29 @@ class _FileExplorerState extends State { @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(begin: const Offset(0, -1), end: Offset.zero).animate( + CurvedAnimation( + parent: _audioBarController, + curve: Curves.easeOutCubic, + ), + ); + context.read().add( + LoadDirectory(orgId: widget.orgId, path: '/'), + ); + context.read().add(LoadPermissions(widget.orgId)); super.initState(); context.read().add( LoadDirectory(orgId: widget.orgId, path: '/'), @@ -863,178 +887,214 @@ class _FileExplorerState extends State { 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().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( - builder: (context, state) { - String currentSort = 'name'; - bool isAscending = true; - if (state is DirectoryLoaded) { - currentSort = state.sortBy; - isAscending = state.isAscending; - } - return Row( - children: [ - PopupMenuButton( - 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().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( + builder: (context, state) { + String currentSort = 'name'; + bool isAscending = true; + if (state is DirectoryLoaded) { + currentSort = state.sortBy; + isAscending = state.isAscending; + } + return Row( + children: [ + PopupMenuButton( + 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().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().add( + ApplySort( + currentSort, + isAscending: !isAscending, + ), + ); + }, + ), ], - onSelected: (value) { - context.read().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().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().add( + ApplyFilter(_searchQuery), + ); + }, ), ), - onChanged: (value) { - _searchQuery = value; - context.read().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 { (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 { if (file.type == FileType.folder) { context.read().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 { ); } } + } 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(); + 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( diff --git a/b0esche_cloud/lib/widgets/audio_player_bar.dart b/b0esche_cloud/lib/widgets/audio_player_bar.dart new file mode 100644 index 0000000..e55a5c9 --- /dev/null +++ b/b0esche_cloud/lib/widgets/audio_player_bar.dart @@ -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 createState() => _AudioPlayerBarState(); +} + +class _AudioPlayerBarState extends State { + 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 _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, + ), + ], + ), + ), + ); + } +} diff --git a/b0esche_cloud/macos/Flutter/GeneratedPluginRegistrant.swift b/b0esche_cloud/macos/Flutter/GeneratedPluginRegistrant.swift index 91f30f3..523c410 100644 --- a/b0esche_cloud/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/b0esche_cloud/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/b0esche_cloud/pubspec.lock b/b0esche_cloud/pubspec.lock index 55b71df..62c328f 100644 --- a/b0esche_cloud/pubspec.lock +++ b/b0esche_cloud/pubspec.lock @@ -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: diff --git a/b0esche_cloud/pubspec.yaml b/b0esche_cloud/pubspec.yaml index 3908a54..2cd1383 100644 --- a/b0esche_cloud/pubspec.yaml +++ b/b0esche_cloud/pubspec.yaml @@ -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: