ryanheise / just_audio

Audio Player
1.05k stars 675 forks source link

Request headers for web #304

Open ryanheise opened 3 years ago

ryanheise commented 3 years ago

Is your feature request related to a problem? Please describe.

Some audio sources require request headers (e.g. to authenticate) but these are not currently supported for web.

Describe the solution you'd like

This could in theory be supported using service workers, although Flutter's support for service workers isn't there yet.

Describe alternatives you've considered

  1. Apps could modify their build process to generate their own service worker JS file.
  2. Server-side solutions.

Additional context

There is a service_worker package but it is not integrated with Flutter's build process.

BartusZak commented 3 years ago

Hey @ryanheise

I would like to be able to set Request Headers for .mp3 files for Web.

Right now it throws 403 error and did not add any headers.

_playlist ![image](https://user-images.githubusercontent.com/16143176/114004136-a48d3f00-985e-11eb-974c-e64ea7e2081c.png)
DevTools Console Error ![image](https://user-images.githubusercontent.com/16143176/114004305-d0102980-985e-11eb-91d9-6bd95b160c07.png)
DevTools Network Tab ![image](https://user-images.githubusercontent.com/16143176/114004558-106fa780-985f-11eb-8ee5-a6b98bece716.png)
AudiobookAudioPlayer.dart ```dart import 'dart:math'; import 'package:audio_session/audio_session.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:just_audio/just_audio.dart'; import 'package:multibook/features/activity/data/models/activity/ActivityModel.dart'; import 'package:multibook/features/activity/data/models/audiobookActivityLastVisited/AudiobookActivityLastVisitedModel.dart'; import 'package:multibook/features/audiobook/bloc/audiobook_bloc.dart'; import 'package:multibook/features/audiobook/data/models/audiobook/AudiobookModel.dart'; import 'package:multibook/repositories/s3/S3Repository.dart'; import 'package:rxdart/rxdart.dart'; class AudiobookAudioPlayer extends StatefulWidget { const AudiobookAudioPlayer({required this.audiobook, required this.activity}); final AudiobookModel? audiobook; final ActivityModel? activity; @override _AudiobookAudioPlayerState createState() => _AudiobookAudioPlayerState(); } class _AudiobookAudioPlayerState extends State { late AudiobookBloc _audiobookBloc; AudioPlayer? _player; S3Repository? _s3Repository; int _addedCount = 0; late ConcatenatingAudioSource _playlist; @override void initState() { super.initState(); _audiobookBloc = BlocProvider.of(context); _s3Repository = RepositoryProvider.of(context); _player = AudioPlayer(); SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( statusBarColor: Colors.black, )); // //PUBLIC - WORKS // _playlist = ConcatenatingAudioSource( // children: widget.audiobook!.content! // .map( // (content) => AudioSource.uri( // Uri.parse(widget.audiobook!.url! + content!.file!), // tag: AudioMetadata( // title: content.title, artwork: widget.audiobook!.cover)), // ) // .toList()); _init(); } _init() async { //PRIVATE _playlist = ConcatenatingAudioSource( children: await Future.wait(widget.audiobook!.content! .map( (content) async => AudioSource.uri( Uri.parse(widget.audiobook!.url! + content!.file!), headers: await _s3Repository!.generateSignedHeaders( widget.audiobook!.url! + content.file!), tag: AudioMetadata( title: content.title, artwork: widget.audiobook!.cover)), ) .toList())); final session = await AudioSession.instance; await session.configure(AudioSessionConfiguration.speech()); try { await _player!.setAudioSource(_playlist, initialIndex: widget.activity?.lastVisited?.index, initialPosition: widget.activity?.lastVisited?.position); } catch (e) { print("An error occured $e"); } } @override void dispose() { if (widget.audiobook!.activityId != null) { _audiobookBloc.add(PutAudiobookActivityForCurrentUserEvent( widget.activity!.activityId, widget.audiobook!.audiobookId, AudiobookActivityLastVisitedModel( _player!.currentIndex, _player!.position))); } else { _audiobookBloc.add(PostAudiobookActivityForCurrentUserEvent( widget.audiobook!.audiobookId, AudiobookActivityLastVisitedModel( _player!.currentIndex, _player!.position))); } _player!.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: StreamBuilder( stream: _player!.sequenceStateStream, builder: (context, snapshot) { final state = snapshot.data; if (state?.sequence.isEmpty ?? true) return SizedBox(); final metadata = state!.currentSource!.tag as AudioMetadata; return Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Padding( padding: const EdgeInsets.all(8.0), child: Center(child: Image.network(metadata.artwork!)), ), ), Text(metadata.album ?? '', style: Theme.of(context).textTheme.headline6), Text(metadata.title ?? ''), ], ); }, ), ), ControlButtons(_player), StreamBuilder( stream: _player!.durationStream, builder: (context, snapshot) { final duration = snapshot.data ?? Duration.zero; return StreamBuilder( stream: Rx.combineLatest2( _player!.positionStream, _player!.bufferedPositionStream, (position, bufferedPosition) => PositionData(position, bufferedPosition)), builder: (context, snapshot) { final positionData = snapshot.data ?? PositionData(Duration.zero, Duration.zero); var position = positionData.position; if (position > duration) { position = duration; } var bufferedPosition = positionData.bufferedPosition; if (bufferedPosition > duration) { bufferedPosition = duration; } return SeekBar( duration: duration, position: position, bufferedPosition: bufferedPosition, onChangeEnd: (newPosition) { _player!.seek(newPosition); }, ); }, ); }, ), SizedBox(height: 8.0), Row( children: [ StreamBuilder( stream: _player!.loopModeStream, builder: (context, snapshot) { final loopMode = snapshot.data ?? LoopMode.off; const icons = [ Icon(Icons.repeat, color: Colors.grey), Icon(Icons.repeat, color: Colors.orange), Icon(Icons.repeat_one, color: Colors.orange), ]; const cycleModes = [ LoopMode.off, LoopMode.all, LoopMode.one, ]; final index = cycleModes.indexOf(loopMode); return IconButton( icon: icons[index], onPressed: () { _player!.setLoopMode(cycleModes[ (cycleModes.indexOf(loopMode) + 1) % cycleModes.length]); }, ); }, ), Expanded( child: Text( "Playlist", style: Theme.of(context).textTheme.headline6, textAlign: TextAlign.center, ), ), StreamBuilder( stream: _player!.shuffleModeEnabledStream, builder: (context, snapshot) { final shuffleModeEnabled = snapshot.data ?? false; return IconButton( icon: shuffleModeEnabled ? Icon(Icons.shuffle, color: Colors.orange) : Icon(Icons.shuffle, color: Colors.grey), onPressed: () async { final enable = !shuffleModeEnabled; if (enable) { await _player!.shuffle(); } await _player!.setShuffleModeEnabled(enable); }, ); }, ), ], ), Container( height: 240.0, child: StreamBuilder( stream: _player!.sequenceStateStream, builder: (context, snapshot) { final state = snapshot.data; final sequence = state?.sequence ?? []; return ReorderableListView( onReorder: (int oldIndex, int newIndex) { if (oldIndex < newIndex) newIndex--; _playlist.move(oldIndex, newIndex); }, children: [ for (var i = 0; i < sequence.length; i++) Dismissible( key: ValueKey(sequence[i]), background: Container( color: Colors.redAccent, alignment: Alignment.centerRight, child: Padding( padding: const EdgeInsets.only(right: 8.0), child: Icon(Icons.delete, color: Colors.white), ), ), onDismissed: (dismissDirection) { _playlist.removeAt(i); }, child: Material( color: i == state!.currentIndex ? Colors.grey.shade300 : null, child: ListTile( title: Text(sequence[i].tag.title), onTap: () { _player!.seek(Duration.zero, index: i); }, ), ), ), ], ); }, ), ), ], ), ); } } class ControlButtons extends StatelessWidget { final AudioPlayer? player; ControlButtons(this.player); @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: Icon(Icons.volume_up), onPressed: () { _showSliderDialog( context: context, title: "Adjust volume", divisions: 10, min: 0.0, max: 1.0, stream: player!.volumeStream, onChanged: player!.setVolume, ); }, ), StreamBuilder( stream: player!.sequenceStateStream, builder: (context, snapshot) => IconButton( icon: Icon(Icons.skip_previous), onPressed: player!.hasPrevious ? player!.seekToPrevious : null, ), ), StreamBuilder( stream: player!.playerStateStream, builder: (context, snapshot) { final playerState = snapshot.data; final processingState = playerState?.processingState; final playing = playerState?.playing; if (processingState == ProcessingState.loading || processingState == ProcessingState.buffering) { return Container( margin: EdgeInsets.all(8.0), width: 64.0, height: 64.0, child: CircularProgressIndicator(), ); } else if (playing != true) { return IconButton( icon: Icon(Icons.play_arrow), iconSize: 64.0, onPressed: player!.play, ); } else if (processingState != ProcessingState.completed) { return IconButton( icon: Icon(Icons.pause), iconSize: 64.0, onPressed: player!.pause, ); } else { return IconButton( icon: Icon(Icons.replay), iconSize: 64.0, onPressed: () => player!.seek(Duration.zero, index: player!.effectiveIndices!.first), ); } }, ), StreamBuilder( stream: player!.sequenceStateStream, builder: (context, snapshot) => IconButton( icon: Icon(Icons.skip_next), onPressed: player!.hasNext ? player!.seekToNext : null, ), ), StreamBuilder( stream: player!.speedStream, builder: (context, snapshot) => IconButton( icon: Text("${snapshot.data?.toStringAsFixed(1)}x", style: TextStyle(fontWeight: FontWeight.bold)), onPressed: () { _showSliderDialog( context: context, title: "Adjust speed", divisions: 10, min: 0.5, max: 1.5, stream: player!.speedStream, onChanged: player!.setSpeed, ); }, ), ), ], ); } } class SeekBar extends StatefulWidget { final Duration duration; final Duration position; final Duration bufferedPosition; final ValueChanged? onChanged; final ValueChanged? onChangeEnd; SeekBar({ required this.duration, required this.position, required this.bufferedPosition, this.onChanged, this.onChangeEnd, }); @override _SeekBarState createState() => _SeekBarState(); } class _SeekBarState extends State { double? _dragValue; late SliderThemeData _sliderThemeData; @override void didChangeDependencies() { super.didChangeDependencies(); _sliderThemeData = SliderTheme.of(context).copyWith( trackHeight: 2.0, ); } @override Widget build(BuildContext context) { return Stack( children: [ SliderTheme( data: _sliderThemeData.copyWith( thumbShape: HiddenThumbComponentShape(), activeTrackColor: Colors.blue.shade100, inactiveTrackColor: Colors.grey.shade300, ), child: ExcludeSemantics( child: Slider( min: 0.0, max: widget.duration.inMilliseconds.toDouble(), value: widget.bufferedPosition.inMilliseconds.toDouble(), onChanged: (value) { setState(() { _dragValue = value; }); if (widget.onChanged != null) { widget.onChanged!(Duration(milliseconds: value.round())); } }, onChangeEnd: (value) { if (widget.onChangeEnd != null) { widget.onChangeEnd!(Duration(milliseconds: value.round())); } _dragValue = null; }, ), ), ), SliderTheme( data: _sliderThemeData.copyWith( inactiveTrackColor: Colors.transparent, ), child: Slider( min: 0.0, max: widget.duration.inMilliseconds.toDouble(), value: min(_dragValue ?? widget.position.inMilliseconds.toDouble(), widget.duration.inMilliseconds.toDouble()), onChanged: (value) { setState(() { _dragValue = value; }); if (widget.onChanged != null) { widget.onChanged!(Duration(milliseconds: value.round())); } }, onChangeEnd: (value) { if (widget.onChangeEnd != null) { widget.onChangeEnd!(Duration(milliseconds: value.round())); } _dragValue = null; }, ), ), Positioned( right: 16.0, bottom: 0.0, child: Text( RegExp(r'((^0*[1-9]\d*:)?\d{2}:\d{2})\.\d+$') .firstMatch("$_remaining") ?.group(1) ?? '$_remaining', style: Theme.of(context).textTheme.caption), ), ], ); } Duration get _remaining => widget.duration - widget.position; } _showSliderDialog({ required BuildContext context, String? title, int? divisions, double? min, double? max, String valueSuffix = '', Stream? stream, ValueChanged? onChanged, }) { showDialog( context: context, builder: (context) => AlertDialog( title: Text(title!, textAlign: TextAlign.center), content: StreamBuilder( stream: stream, builder: (context, snapshot) => Container( height: 100.0, child: Column( children: [ Text('${snapshot.data?.toStringAsFixed(1)}$valueSuffix', style: TextStyle( fontFamily: 'Fixed', fontWeight: FontWeight.bold, fontSize: 24.0)), Slider( divisions: divisions, min: min!, max: max!, value: snapshot.data ?? 1.0, onChanged: onChanged, ), ], ), ), ), ), ); } class AudioMetadata { final String? album; final String? title; final String? artwork; AudioMetadata({this.album, this.title, this.artwork}); } class HiddenThumbComponentShape extends SliderComponentShape { @override Size getPreferredSize(bool isEnabled, bool isDiscrete) => Size.zero; @override void paint( PaintingContext context, Offset center, { Animation? activationAnimation, Animation? enableAnimation, bool? isDiscrete, TextPainter? labelPainter, RenderBox? parentBox, SliderThemeData? sliderTheme, TextDirection? textDirection, double? value, double? textScaleFactor, Size? sizeWithOverflow, }) {} } class PositionData { final Duration position; final Duration bufferedPosition; PositionData(this.position, this.bufferedPosition); } ```
Generating SigV4 Headers for Amazon S3 ```dart Map generateSignedHeaders(String url, {String method = 'GET'}) => _sigV4S3Client.signedHeaders(url, method: method); ```
flutter doctor -v ``` [√] Flutter (Channel stable, 2.0.3, on Microsoft Windows [Version 10.0.19042.867], locale en-US) • Flutter version 2.0.3 at C:\FlutterSDK\flutter • Framework revision 4d7946a68d (3 weeks ago), 2021-03-18 17:24:33 -0700 • Engine revision 3459eb2436 • Dart version 2.12.2 [√] Android toolchain - develop for Android devices (Android SDK version 30.0.3) • Android SDK at C:\Users\bplos\AppData\Local\Android\sdk • Platform android-30, build-tools 30.0.3 • Java binary at: C:\Program Files\Android\Android Studio\jre\bin\java • Java version OpenJDK Runtime Environment (build 1.8.0_242-release-1644-b01) • All Android licenses accepted. [√] Chrome - develop for the web • Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe [√] Android Studio (version 4.1.0) • Android Studio at C:\Program Files\Android\Android Studio • Flutter plugin can be installed from: https://plugins.jetbrains.com/plugin/9212-flutter • Dart plugin can be installed from: https://plugins.jetbrains.com/plugin/6351-dart • Java version OpenJDK Runtime Environment (build 1.8.0_242-release-1644-b01) [√] VS Code (version 1.55.0) • VS Code at C:\Users\bplos\AppData\Local\Programs\Microsoft VS Code • Flutter extension version 3.21.0 [√] Connected device (3 available) • Redmi Note 5 (mobile) • df6a6acb • android-arm64 • Android 9 (API 28) • Chrome (web) • chrome • web-javascript • Google Chrome 89.0.4389.114 • Edge (web) • edge • web-javascript • Microsoft Edge 89.0.774.68 • No issues found! ```

pubspec.yaml

  sigv4:
    git:
      url: https://github.com/bartuszak/sigv4.git
      ref: null-safety
  just_audio: ^0.7.3

Flutter team added possibility to attach request headers with Video Player (maybe it can help) https://github.com/flutter/plugins/pull/3671

ryanheise commented 3 years ago

Regarding video_player, I don't think it has headers for web yet, so I think it's more or less caught up to just_audio which already has headers for all platform "except" for web.

For web, I'm waiting for a way to write a custom service worker and link it into Flutter's build process.

BartusZak commented 3 years ago

@ryanheise

Yes, it is. There is a working example with video_player and signed headers https://github.com/balvinderz/video_player_web_hls/pull/10/commits/761bbd3f36805cee0cb7bfa9956d00bb9b22dd31

It's just a POC and still need improvements, but it works https://github.com/balvinderz/video_player_web_hls/issues/8

Feel free to check out provided links for details.

HLS with Secured Headers for audio is one of core requirements that I have to implement.

I can use video_player to handle it but I feel like it's totally not what is should look like.

Can you please give me any advice what should I do? Thanks in advance.

BartusZak commented 3 years ago

@ryanheise any update on that one?

Is there any way to add request headers to a source with HTTPS request on WEB?

eg.:

  _playlist = ConcatenatingAudioSource(
      children: widget.audiobook.content
          .map(
            (content) => AudioSource.uri(
                Uri.parse(widget.audiobook.url + content.file),
                headers: _s3Repository.generateSignedHeaders(
                    widget.audiobook.url + content.file),
                tag: AudioMetadata(
                    title: content.title, artwork: widget.audiobook.cover)),
          )
          .toList());
BartusZak commented 3 years ago

@kamilsztandur

ryanheise commented 3 years ago

I'm sorry I have been a bit busy on other issues, and so this hasn't been the highest priority.

However, I'm happy to accept pull requests or also discussions suggesting how to go about implementing this.

Your linked example seems interesting, but to help things along, can you summarise the key points its approach and what needs to be done to copy that approach? For example, does it use something else instead of service workers?