import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:flutter/foundation.dart'; import 'package:freezer/api/cache.dart'; import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/deezer_audio_source.dart'; import 'package:freezer/api/offline_audio_source.dart'; import 'package:freezer/api/paths.dart'; import 'package:freezer/api/player/player_helper.dart'; import 'package:freezer/api/url_audio_source.dart'; import 'package:freezer/ui/android_auto.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:just_audio/just_audio.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:just_audio_media_kit/just_audio_media_kit.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:collection/collection.dart'; import 'package:scrobblenaut/scrobblenaut.dart'; import '../definitions.dart'; import '../../settings.dart'; import 'dart:io'; import 'dart:async'; import 'dart:convert'; PlayerHelper playerHelper = PlayerHelper(); late AudioHandler audioHandler; class AudioPlayerTaskInitArguments { final bool ignoreInterruptions; final bool seekAsSkip; final DeezerAPI deezerAPI; final bool logListen; final String? lastFMUsername; final String? lastFMPassword; AudioPlayerTaskInitArguments({ required this.deezerAPI, required this.ignoreInterruptions, required this.seekAsSkip, required this.logListen, required this.lastFMUsername, required this.lastFMPassword, }); static AudioPlayerTaskInitArguments from( {required Settings settings, required DeezerAPI deezerAPI}) { return AudioPlayerTaskInitArguments( deezerAPI: deezerAPI, logListen: settings.logListen, ignoreInterruptions: settings.ignoreInterruptions, seekAsSkip: settings.seekAsSkip, lastFMUsername: settings.lastFMUsername, lastFMPassword: settings.lastFMPassword); } static Future loadSettings() async { final settings = await Settings.load(); final deezerAPI = DeezerAPI()..arl = settings.arl; await deezerAPI.authorize(); return from(settings: settings, deezerAPI: deezerAPI); } } class AudioPlayerTask extends BaseAudioHandler { final _logger = Logger('AudioPlayerTask'); late AudioPlayer _player; late ConcatenatingAudioSource _audioSource; late DeezerAPI _deezerAPI; late bool _seekAsSkip; /// Original queue from shuffle List? _originalQueue; /// Current index in queue int _queueIndex = 0; /// Used to assign unique ids to queue items. /// This shall not be decremented, and shall only be reset when the queue is being recreated. /// /// USE CASE: assigning keys to queue items (for example in [QueueScreen]) int _queueAutoIncrement = 0; //Stream subscriptions StreamSubscription? _eventSubscription; StreamSubscription? _bufferPositionSubscription; StreamSubscription? _audioSessionSubscription; StreamSubscription? _visualizerSubscription; StreamSubscription? _connectivitySubscription; bool _isConnectivityPluginAvailable = true; /// Android Auto helper class for navigation late final AndroidAuto _androidAuto; //Loaded from file/frontendjust AudioQuality mobileQuality = AudioQuality.MP3_128; AudioQuality wifiQuality = AudioQuality.MP3_128; AudioQuality _currentQuality = AudioQuality.MP3_128; /// Current queueSource (=> where playback has begun from) QueueSource? queueSource; /// Last playback position (used for restoring position when player died) Duration? _lastPosition; AudioServiceRepeatMode _repeatMode = AudioServiceRepeatMode.none; /// LastFM API Scrobblenaut? _scrobblenaut; /// Last logged track id (to avoid duplicates) String? _loggedTrackId; /// Last track's id String? _lastTrackId; // deezer track logging /// Whether we should send log requests to deezer late bool _shouldLogTracks; /// Amount of times the song's been paused int _amountPaused = 0; /// Amount of times the song's been seeked int _amountSeeked = 0; /// When playback begun (in SECONDS) int? _timestamp; MediaItem get currentMediaItem => queue.value[_queueIndex]; bool get currentMediaItemIsShow => queue.value.elementAtOrNull(_queueIndex)?.extras?['show'] != null; /// Hive playback box (LazyBox = don't keep stuff in RAM) late final LazyBox _box; AudioPlayerTask([AudioPlayerTaskInitArguments? initArgs]) { if (initArgs == null) { unawaited(AudioPlayerTaskInitArguments.loadSettings().then(_init)); return; } unawaited(_init(initArgs)); } Future _init(AudioPlayerTaskInitArguments initArgs) async { // Linux/Windows specific options JustAudioMediaKit.title = 'Freezer'; JustAudioMediaKit.protocolWhitelist = const ['http']; _deezerAPI = initArgs.deezerAPI; _androidAuto = AndroidAuto(deezerAPI: _deezerAPI); _shouldLogTracks = initArgs.logListen; _seekAsSkip = initArgs.seekAsSkip; final session = await AudioSession.instance; session.configure(const AudioSessionConfiguration.music()); _box = await Hive.openLazyBox('playback', path: await Paths.cacheDir()); _player = AudioPlayer( handleInterruptions: !initArgs.ignoreInterruptions, androidApplyAudioAttributes: true, handleAudioSessionActivation: true, ); if (initArgs.ignoreInterruptions) { //session.interruptionEventStream.listen((_) {}); //session.becomingNoisyEventStream.listen((_) {}); } //Update track index _player.currentIndexStream.listen((index) { if (index != null && queue.value.isNotEmpty) { _queueIndex = index; mediaItem.add(currentMediaItem); // log previous track if (index != 0 && _lastTrackId != null && _lastTrackId! != currentMediaItem.id) { _logListenedTrack(_lastTrackId!, sync: _amountPaused == 0 && _amountSeeked == 0); } _lastTrackId = currentMediaItem.id; _amountSeeked = 0; _amountPaused = 0; _timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000; } if (index == queue.value.length - 1) { unawaited(_onQueueEnd()); } }); //Update state on all clients on change _eventSubscription = _player.playbackEventStream.listen((event) { //Update _broadcastState(); }, onError: (Object e, StackTrace st) { _logger.severe('A stream error occurred: $e'); }); _player.processingStateStream.listen((state) { switch (state) { case ProcessingState.completed: //Player ended, get more songs if (_queueIndex == queue.value.length - 1) { customEvent.add( {'action': 'queueEnd', 'queueSource': queueSource!.toJson()}); } break; default: break; } }); _bufferPositionSubscription = _player.bufferedPositionStream.listen((bufferPosition) { customEvent.add({'action': 'bufferPosition', 'data': bufferPosition}); }); //Audio session // _audioSessionSubscription = // _player.androidAudioSessionIdStream.listen((event) { // customEvent.add({'action': 'audioSession', 'id': event}); // }); //Load queue // queue.add(_queue); // Determine audio quality to use if (await _determineAudioQuality()) { // listen for connectivity changes _connectivitySubscription = Connectivity() .onConnectivityChanged .listen(_determineAudioQualityByResult); } await _loadQueueFile(); if (initArgs.lastFMUsername != null && initArgs.lastFMPassword != null) { await _authorizeLastFM( initArgs.lastFMUsername!, initArgs.lastFMPassword!); } customEvent.add({'action': 'onLoad'}); } /// Determine the [AudioQuality] to use according to current connection /// /// Returns whether the [Connectivity] plugin is available on this system or not Future _determineAudioQuality() async { if (_isConnectivityPluginAvailable) { try { await Connectivity() .checkConnectivity() .then(_determineAudioQualityByResult); return true; } catch (e) { _isConnectivityPluginAvailable = false; customEvent.add({'action': 'connectivityPlugin', 'available': false}); } } _logger.warning( 'Couldn\'t determine connection! Falling back to other (which may use wifi quality)'); // on error, return dummy value -- error can happen on linux if not using NetworkManager, for example _determineAudioQualityByResult(ConnectivityResult.other); return false; } /// Determines the [AudioQuality] to use according to [result] void _determineAudioQualityByResult(ConnectivityResult result) { switch (result) { case ConnectivityResult.mobile: case ConnectivityResult.bluetooth: _currentQuality = mobileQuality; case ConnectivityResult.other: _currentQuality = Platform.isLinux || Platform.isLinux || Platform.isMacOS ? wifiQuality : mobileQuality; default: _currentQuality = wifiQuality; } print('quality: $_currentQuality'); } @override Future skipToQueueItem(int index) async { _lastPosition = null; unawaited(_logListenedTrack(currentMediaItem.id, sync: false)); //Skip in player await _player.seek(Duration.zero, index: index); _queueIndex = index; play(); } @override Future play() async { _player.play(); //Restore position on play if (_lastPosition != null) { _player.seek(_lastPosition); _lastPosition = null; } //LastFM if (_queueIndex >= queue.value.length) return; if (_scrobblenaut != null && currentMediaItem.id != _loggedTrackId) { _loggedTrackId = currentMediaItem.id; await _scrobblenaut!.track.scrobble( track: currentMediaItem.title, artist: currentMediaItem.artist!, album: currentMediaItem.album, duration: currentMediaItem.duration, ); } } @override Future pause() { _amountPaused++; return _player.pause(); } @override Future seek(Duration? position) { _amountSeeked++; return _player.seek(position); } @override Future fastForward() { print('fast forward called'); if (currentMediaItemIsShow) { return _seekRelative(const Duration(seconds: 30)); } if (_seekAsSkip) return skipToNext(); return Future.value(); } @override Future rewind() { print('rewind called'); if (currentMediaItemIsShow) { return _seekRelative(-const Duration(seconds: 30)); } if (_seekAsSkip) return skipToPrevious(); return Future.value(); } // unused. @override Future seekForward(bool begin) => Future.value(); Future seekBackward(bool begin) async { // (un)favourite action if (cache.libraryTracks.contains(currentMediaItem.id)) { cache.libraryTracks.remove(currentMediaItem.id); _broadcastState(); return _deezerAPI.removeFavorite(currentMediaItem.id); } cache.libraryTracks.add(currentMediaItem.id); _broadcastState(); return _deezerAPI.addFavoriteTrack(currentMediaItem.id); } //Remove item from queue @override Future removeQueueItem(MediaItem mediaItem) async { int index = queue.value.indexWhere((m) => m.id == mediaItem.id); removeQueueItemAt(index); } @override Future removeQueueItemAt(int index) async { if (index <= _queueIndex) { _queueIndex--; } await _audioSource.removeAt(index); queue.add(queue.value..removeAt(index)); } @override Future skipToNext() async { _lastPosition = null; if (_queueIndex == queue.value.length - 1) return; //Update buffering state unawaited(_logListenedTrack(currentMediaItem.id, sync: false, next: true)); _queueIndex++; await _player.seekToNext(); _broadcastState(); } @override Future skipToPrevious() async { if (_queueIndex == 0) return; //Update buffering state //_skipState = AudioProcessingState.skippingToPrevious; //Normal skip to previous unawaited(_logListenedTrack(currentMediaItem.id, sync: false, prev: true)); _queueIndex--; await _player.seekToPrevious(); //_skipState = null; } Future _logListenedTrack(String trackId, {required bool sync, bool next = false, bool prev = false}) async { if (_loggedTrackId == trackId) return; _loggedTrackId = trackId; print( 'logging: seek: $_amountSeeked, pause: $_amountPaused, sync: $sync, next: $next, prev: $prev, timestamp: $_timestamp (elapsed: ${DateTime.now().millisecondsSinceEpoch ~/ 1000 - _timestamp!}s)'); if (!_shouldLogTracks) return; //Log to Deezer _deezerAPI.logListen( trackId, seek: _amountSeeked, pause: _amountPaused, sync: sync ? 1 : 0, timestamp: _timestamp, prev: prev, next: next, ); } @override Future> getChildren(String parentMediaId, [Map? options]) async { return await _androidAuto.getScreen(parentMediaId); } //Relative seek Future _seekRelative(Duration offset) async { Duration newPos = _player.position + offset; //Out of bounds check if (newPos < Duration.zero) newPos = Duration.zero; if (newPos > currentMediaItem.duration!) { newPos = currentMediaItem.duration!; } await _player.seek(newPos); } MediaControl favoriteControl() { if (cache.libraryTracks.contains(currentMediaItem.id)) { return const MediaControl( androidIcon: 'drawable/ic_heart', label: 'unfavorite', action: MediaAction.custom); } return const MediaControl( androidIcon: 'drawable/ic_heart_outline', label: 'favourite', action: MediaAction .custom, // this acts as favourite/unfavourite as it's not already used. ); } //Update state on all clients void _broadcastState() { playbackState.add(PlaybackState( controls: !currentMediaItemIsShow ? [ if (queue.value.isNotEmpty) favoriteControl(), /*if (_queueIndex != 0)*/ MediaControl.skipToPrevious, _player.playing ? MediaControl.pause : MediaControl.play, /**/ MediaControl.skipToNext, //Stop -- USELESS. // MediaControl( // androidIcon: 'drawable/ic_action_stop', // label: 'stop', // action: MediaAction.stop), // i mean, the user can just swipe the notification away to stop ] : [ const MediaControl( androidIcon: 'drawable/ic_replay_30', label: 'replay 30', action: MediaAction.rewind, ), // acts as prev-30 _player.playing ? MediaControl.pause : MediaControl.play, const MediaControl( androidIcon: 'drawable/ic_forward_30', label: 'forward 30', action: MediaAction.fastForward, ), // next-30 ], systemActions: !currentMediaItemIsShow ? { MediaAction.seek, MediaAction.seekBackward, if (queue.hasValue && _queueIndex != queue.value.length - 1) MediaAction.skipToNext, if (_queueIndex != 0) MediaAction.skipToPrevious, MediaAction.stop } : const { MediaAction.seek, MediaAction.fastForward, MediaAction.rewind, MediaAction.stop, }, processingState: _convertProcessingState(_player.processingState), playing: _player.playing, updatePosition: _player.position, bufferedPosition: _player.bufferedPosition, speed: _player.speed, queueIndex: _queueIndex, )); } //just_audio state -> audio_service state. If skipping, use _skipState AudioProcessingState _convertProcessingState( ProcessingState processingState) { return const { ProcessingState.idle: AudioProcessingState.idle, ProcessingState.loading: AudioProcessingState.loading, ProcessingState.buffering: AudioProcessingState.buffering, ProcessingState.ready: AudioProcessingState.ready, ProcessingState.completed: AudioProcessingState.completed, }[processingState]!; } //Replace current queue @override // ignore: avoid_renaming_method_parameters Future updateQueue(List q) async { _lastPosition = null; //just_audio _originalQueue = null; _player.stop(); // assign unique ids _queueAutoIncrement = 0; for (var e in q) { e.extras!['id'] = _queueAutoIncrement++; } queue.add(q); //Load await _loadQueue(); //await _player.seek(Duration.zero, index: 0); -- will be done in frontend } //Load queue to just_audio Future _loadQueue() async { //Don't reset queue index by starting player if (!queue.hasValue || queue.value.isEmpty) { return; } final sources = await Future.wait(queue.value.map((e) => _mediaItemToAudioSource(e))); _audioSource = ConcatenatingAudioSource( children: sources, // load stuff as late as possible => less data used useLazyPreparation: true, ); //Load in just_audio try { await _player.setAudioSource(_audioSource, initialIndex: _queueIndex, initialPosition: Duration.zero, preload: kIsWeb || defaultTargetPlatform != TargetPlatform.linux); } catch (e) { //Error loading tracks } if (_queueIndex != -1) { mediaItem.add(currentMediaItem); } } Future _mediaItemToAudioSource(MediaItem mediaItem) async { //Check if offline if (Platform.isAndroid) { String offlinePath = p.join((await getExternalStorageDirectory())!.path, 'offline/'); File file = File(p.join(offlinePath, mediaItem.id)); if (await file.exists()) { // return Uri.file(f.path); //return f.path; //Stream server URL // return 'http://localhost:36958/?id=${mediaItem.id}'; // maybe still use [AudioSource.file], but find another way to broadcast stream info so to have less overhead return OfflineAudioSource(file, onStreamObtained: (streamInfo) => customEvent.add({'action': 'streamInfo', 'data': streamInfo})); // return AudioSource.uri( // Uri.http('localhost:36958', '/', {'id': mediaItem.id})); } } //Show episode direct link if (mediaItem.extras!['showUrl'] != null) { return UrlAudioSource( Uri.parse(mediaItem.extras!['showUrl']), onStreamObtained: (qualityInfo) => customEvent.add({'action': 'streamInfo', 'data': qualityInfo}), ); } //Due to current limitations of just_audio, quality fallback moved to DeezerDataSource in ExoPlayer //This just returns fake url that contains metadata List? playbackDetails = jsonDecode(mediaItem.extras!['playbackDetails']); if ((playbackDetails ?? []).length < 2) { throw Exception('not enough playback details'); } //String url = 'https://dzcdn.net/?md5=${playbackDetails[0]}&mv=${playbackDetails[1]}&q=${quality.toString()}#${mediaItem.id}'; // final uri = Uri.http('localhost:36958', '', { // if (quality != null) 'q': quality.toString(), // 'mv': playbackDetails![1], // 'md5origin': playbackDetails[0], // 'id': mediaItem.id, // }); // print("url for " + mediaItem.title + ": " + uri.toString()); // return AudioSource.uri(uri); // DON'T use the java backend anymore, useless. return DeezerAudioSource( getQuality: () => _currentQuality, trackId: mediaItem.id, trackToken: mediaItem.extras!['trackToken'] ?? '', trackTokenExpiration: mediaItem.extras!['trackTokenExpiration'] ?? 0, md5origin: playbackDetails![0], mediaVersion: playbackDetails[1], onStreamObtained: (qualityInfo) => customEvent.add({'action': 'streamInfo', 'data': qualityInfo}), ); } //Custom actions @override Future customAction(String name, [Map? extras]) async { switch (name) { case 'updateQuality': //Pass wifi & mobile quality by custom action //Isolate can't access globals wifiQuality = extras!['wifiQuality'] as AudioQuality; mobileQuality = extras['mobileQuality'] as AudioQuality; _determineAudioQuality(); break; //Update queue source case 'queueSource': queueSource = QueueSource.fromJson(extras!); break; //Looping // case 'repeatType': // _loopMode = LoopMode.values[args!['type']]; // _player.setLoopMode(_loopMode); // break; //Save queue case 'saveQueue': await _saveQueue(); break; //Reorder tracks, args = [old, new] case 'reorder': final oldIndex = extras!['oldIndex']! as int; final newIndex = extras['newIndex']! as int; await _audioSource.move( oldIndex, oldIndex > newIndex ? newIndex : newIndex - 1); queue.add(List.from(queue.value)..reorder(oldIndex, newIndex)); _broadcastState(); break; //Set index without affecting playback for loading case 'setIndex': _queueIndex = extras!['index']; break; //Start visualizer // case 'startVisualizer': // if (_visualizerSubscription != null) break; // _player.startVisualizer( // enableWaveform: false, // enableFft: true, // captureRate: 15000, // captureSize: 128); // _visualizerSubscription = _player.visualizerFftStream.listen((event) { // //Calculate actual values // List out = []; // for (int i = 0; i < event.length / 2; i++) { // int rfk = event[i * 2].toSigned(8); // int ifk = event[i * 2 + 1].toSigned(8); // out.add(log(hypot(rfk, ifk) + 1) / 5.2); // } // AudioServiceBackground.sendCustomEvent( // {"action": "visualizer", "data": out}); // }); // break; // //Stop visualizer // case 'stopVisualizer': // if (_visualizerSubscription != null) { // _visualizerSubscription.cancel(); // _visualizerSubscription = null; // } // break; //Authorize lastfm case 'authorizeLastFM': final username = extras!['username']! as String; final password = extras['password']! as String; await _authorizeLastFM(username, password); break; case 'disableLastFM': _scrobblenaut = null; break; } return true; } @override Future onTaskRemoved() => stop(); @override Future onNotificationDeleted() => stop(); @override Future stop() async { await _saveQueue(); _player.stop(); _eventSubscription?.cancel(); _audioSessionSubscription?.cancel(); _visualizerSubscription?.cancel(); _bufferPositionSubscription?.cancel(); _connectivitySubscription?.cancel(); await super.stop(); } //Export queue to -JSON- hive box Future _saveQueue() async { if (_queueIndex == 0 && queue.value.isEmpty) return; await _box.clear(); await _box.putAll({ 'index': _queueIndex, 'queue': queue.value, 'position': _player.position, 'queueSource': queueSource, 'repeatMode': _repeatMode, }); } //Restore queue & playback info from path Future _loadQueueFile() async { if (_box.isEmpty) { await _loadQueue(); return; } final q = ((await _box.get('queue')) as List?)?.cast(); _queueIndex = await _box.get('index', defaultValue: 0); _lastPosition = await _box.get('position', defaultValue: Duration.zero); queueSource = await _box.get('queueSource', defaultValue: const QueueSource()); _repeatMode = await _box.get('repeatMode', defaultValue: AudioServiceRepeatMode.none); //Restore queue if (q != null) { _queueAutoIncrement = q.length; queue.add(q); await _loadQueue(); mediaItem.add(currentMediaItem); } else { await _loadQueue(); } //Send restored queue source to ui customEvent.add({ 'action': 'onRestore', 'queueSource': queueSource!, 'repeatMode': _repeatMode }); } @override Future insertQueueItem(int index, MediaItem mediaItem) async { //-1 == play next if (index == -1) index = _queueIndex + 1; queue.add(List.from(queue.value) ..insert(index, mediaItem..extras!['id'] = _queueAutoIncrement++)); AudioSource? newSource = await _mediaItemToAudioSource(mediaItem); await _audioSource.insert(index, newSource); _saveQueue(); } //Add at end of queue @override Future addQueueItem(MediaItem mediaItem, {bool shouldSaveQueue = true}) async { if (queue.value.indexWhere((m) => m.id == mediaItem.id) != -1) return; queue.add(List.from(queue.value) ..add(mediaItem..extras!['id'] = _queueAutoIncrement++)); AudioSource newSource = await _mediaItemToAudioSource(mediaItem); await _audioSource.add(newSource); if (shouldSaveQueue) _saveQueue(); } @override Future addQueueItems(List mediaItems) async { for (final mediaItem in mediaItems) { await addQueueItem(mediaItem, shouldSaveQueue: false); } _saveQueue(); } @override Future playFromMediaId(String mediaId, [Map? extras]) async { //Android auto load tracks if (mediaId.startsWith(AndroidAuto.prefix)) { await _androidAuto.playItem(mediaId.substring(AndroidAuto.prefix.length)); return; } //Does the same thing await skipToQueueItem(queue.value.indexWhere((item) => item.id == mediaId)); } @override Future getMediaItem(String mediaId) async => queue.value.firstWhereOrNull((mediaItem) => mediaItem.id == mediaId); @override Future playMediaItem(MediaItem mediaItem) => playFromMediaId(mediaItem.id); @override Future setRepeatMode(AudioServiceRepeatMode repeatMode) => _player.setLoopMode(repeatMode.toLoopMode()); @override Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { switch (shuffleMode) { case AudioServiceShuffleMode.none: queue.add(_originalQueue!); _originalQueue = null; break; case AudioServiceShuffleMode.group: case AudioServiceShuffleMode.all: _originalQueue = List.from(queue.value, growable: false); queue.add(queue.value..shuffle()); break; } _queueIndex = 0; await _player.stop(); await _loadQueue(); await _player.play(); } //Called when queue ends to load more tracks Future _onQueueEnd() async { if (queueSource == null) return; List? tracks; switch (queueSource!.source) { //Flow case 'flow': tracks = await _deezerAPI.flow(queueSource!.id); break; //SmartRadio/Artist radio case 'smartradio': tracks = await _deezerAPI.smartRadio(queueSource!.id!); break; //Library shuffle case 'libraryshuffle': tracks = await _deezerAPI.libraryShuffle( start: audioHandler.queue.value.length); break; case 'mix': tracks = await _deezerAPI.playMix(queueSource!.id); // Deduplicate tracks with the same id // List queueIds = queue.value.map((e) => e.id).toList(); // tracks?.removeWhere((track) => queueIds.contains(track.id)); break; case 'smarttracklist': tracks = (await _deezerAPI.smartTrackList(queueSource!.id!)).tracks; case 'searchMix': tracks = await _deezerAPI.getSearchTrackMix(queueSource!.id!, null); default: return; // print(queueSource.toJson()); } if (tracks == null) { throw Exception( 'Failed to fetch more queue songs (queueSource: ${queueSource!.toJson()})'); } final mi = tracks.map((t) => t.toMediaItem()).toList(growable: false); await addQueueItems(mi); } Future _authorizeLastFM(String username, String password) async { _scrobblenaut = Scrobblenaut( lastFM: await LastFM.authenticateWithPasswordHash( apiKey: 'b6ab5ae967bcd8b10b23f68f42493829', apiSecret: '861b0dff9a8a574bec747f9dab8b82bf', username: username, passwordHash: password)); } } //Seeker from audio_service example (why reinvent the wheel?) //While holding seek button, will continuously seek class Seeker { final AudioPlayer? player; final Duration positionInterval; final Duration stepInterval; final MediaItem mediaItem; bool _running = false; Seeker(this.player, this.positionInterval, this.stepInterval, this.mediaItem); Future start() async { _running = true; while (_running) { Duration newPosition = player!.position + positionInterval; if (newPosition < Duration.zero) newPosition = Duration.zero; if (newPosition > mediaItem.duration!) newPosition = mediaItem.duration!; player!.seek(newPosition); await Future.delayed(stepInterval); } } void stop() { _running = false; } }