diff --git a/lib/components/PageWindowTitleBar.dart b/lib/components/PageWindowTitleBar.dart index 85caf6a7..97c35880 100644 --- a/lib/components/PageWindowTitleBar.dart +++ b/lib/components/PageWindowTitleBar.dart @@ -1,15 +1,11 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart'; -import 'package:mpv_dart/mpv_dart.dart'; -import 'package:provider/provider.dart'; -import 'package:spotube/provider/PlayerDI.dart'; class TitleBarActionButtons extends StatelessWidget { const TitleBarActionButtons({Key? key}) : super(key: key); @override Widget build(BuildContext context) { - MPVPlayer player = context.watch().player; return Row( children: [ TextButton( @@ -32,7 +28,6 @@ class TitleBarActionButtons extends StatelessWidget { child: const Icon(Icons.crop_square_rounded)), TextButton( onPressed: () { - player.stop(); appWindow.close(); }, style: ButtonStyle( diff --git a/lib/components/Player.dart b/lib/components/Player.dart index ef1eae77..b1cbf516 100644 --- a/lib/components/Player.dart +++ b/lib/components/Player.dart @@ -1,16 +1,16 @@ -import 'dart:io'; +import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/foundation.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/components/PlayerControls.dart'; import 'package:spotube/helpers/artist-to-string.dart'; +import 'package:spotube/helpers/search-youtube.dart'; import 'package:spotube/models/GlobalKeyActions.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:flutter/material.dart'; -import 'package:mpv_dart/mpv_dart.dart'; import 'package:provider/provider.dart'; -import 'package:spotube/provider/PlayerDI.dart'; import 'package:spotube/provider/SpotifyDI.dart'; class Player extends StatefulWidget { @@ -20,169 +20,200 @@ class Player extends StatefulWidget { _PlayerState createState() => _PlayerState(); } -class _PlayerState extends State { +class _PlayerState extends State with WidgetsBindingObserver { + late AudioPlayer player; bool _isPlaying = false; bool _shuffled = false; - double _duration = 0; + Duration? _duration; - String? _currentPlaylistId; + String? _currentTrackId; double _volume = 0; - List _hotKeys = []; + final List _hotKeys = []; @override void initState() { + super.initState(); + player = AudioPlayer(); + WidgetsBinding.instance?.addObserver(this); WidgetsBinding.instance?.addPostFrameCallback((timeStamp) async { try { - MPVPlayer player = context.read().player; - if (!player.isRunning()) { - await player.start(); - } - double volume = await player.getProperty("volume"); setState(() { - _volume = volume / 100; + _volume = player.volume; }); - player.on(MPVEvents.paused, null, (ev, context) { + player.playingStream.listen((playing) async { setState(() { - _isPlaying = false; + _isPlaying = playing; }); }); - player.on(MPVEvents.resumed, null, (ev, context) { - setState(() { - _isPlaying = true; - }); - }); - - player.on(MPVEvents.status, null, (ev, _) async { - Map data = ev.eventData as Map; - Playback playback = context.read(); - - if (data["property"] == "media-title" && data["value"] != null) { - var track = playback.currentPlaylist?.tracks.where((track) { - String title = data["value"]; - return title.contains(track.name!) && - track.artists! - .every((artist) => title.contains(artist.name!)); - }); - if (track != null && track.isNotEmpty) { - setState(() { - _isPlaying = true; - }); - playback.setCurrentTrack = track.first; + player.durationStream.listen((duration) async { + if (duration != null) { + // Actually things doesn't work all the time as they were + // described. So instead of listening to a `playback.ready` + // stream, it has to listen to duration stream since duration + // is always added to the Stream sink after all icyMetadata has + // been loaded thus indicating buffering started + if (duration != Duration.zero && duration != _duration) { + // this line is for prev/next or already playing playlist + if (player.playing) await player.pause(); + await player.play(); } - } - if (data["property"] == "duration" && data["value"] != null) { setState(() { - _duration = data["value"]; + _duration = duration; }); } - playOrPause(key) async { - _isPlaying ? await player.pause() : await player.play(); - } - - List keyWithActions = [ - GlobalKeyActions( - HotKey(KeyCode.space, scope: HotKeyScope.inapp), - playOrPause, - ), - GlobalKeyActions( - HotKey(KeyCode.mediaPlayPause), - playOrPause, - ), - GlobalKeyActions(HotKey(KeyCode.mediaTrackNext), (key) async { - await player.next(); - }), - GlobalKeyActions(HotKey(KeyCode.mediaTrackPrevious), (key) async { - await player.prev(); - }), - GlobalKeyActions(HotKey(KeyCode.mediaStop), (key) async { - await player.stop(); - setState(() { - _isPlaying = false; - _currentPlaylistId = null; - _duration = 0; - _shuffled = false; - }); - playback.reset(); - }) - ]; - - await Future.wait( - keyWithActions.map((e) { - return hotKeyManager.register( - e.hotKey, - keyDownHandler: e.onKeyDown, - ); - }), - ); }); - } catch (e) { - if (kDebugMode) { - print("[PLAYER]: $e"); + + player.processingStateStream.listen((event) async { + try { + if (event == ProcessingState.completed && _currentTrackId != null) { + _movePlaylistPositionBy(1); + } + } catch (e, stack) { + print("[PrecessingStateStreamListener] $e"); + print(stack); + } + }); + + playOrPause(key) async { + try { + _isPlaying ? await player.pause() : await player.play(); + } catch (e, stack) { + print("[PlayPauseShortcut] $e"); + print(stack); + } } + + List keyWithActions = [ + GlobalKeyActions( + HotKey(KeyCode.space, scope: HotKeyScope.inapp), + playOrPause, + ), + GlobalKeyActions( + HotKey(KeyCode.mediaPlayPause), + playOrPause, + ), + GlobalKeyActions(HotKey(KeyCode.mediaTrackNext), (key) async { + _movePlaylistPositionBy(1); + }), + GlobalKeyActions(HotKey(KeyCode.mediaTrackPrevious), (key) async { + _movePlaylistPositionBy(-1); + }), + GlobalKeyActions(HotKey(KeyCode.mediaStop), (key) async { + Playback playback = context.read(); + setState(() { + _isPlaying = false; + _currentTrackId = null; + _duration = null; + _shuffled = false; + }); + playback.reset(); + }) + ]; + + await Future.wait( + keyWithActions.map((e) { + return hotKeyManager.register( + e.hotKey, + keyDownHandler: e.onKeyDown, + ); + }), + ); + } catch (e) { + print("[Player.initState()]: $e"); } }); - super.initState(); } @override - void dispose() async { - MPVPlayer player = context.read().player; - player.removeAllByEvent(MPVEvents.paused); - player.removeAllByEvent(MPVEvents.resumed); - player.removeAllByEvent(MPVEvents.status); - await Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e))); + void dispose() { + WidgetsBinding.instance?.removeObserver(this); + player.dispose(); + Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e))); super.dispose(); } - String playlistToStr(CurrentPlaylist playlist) { - var tracks = playlist.tracks.map((track) { - var artists = artistsToString(track.artists ?? []); - var title = track.name?.replaceAll("-", " "); - - return "ytdl://ytsearch:$artists - $title"; - }).toList(); - - return tracks.join("\n"); + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + // Release the player's resources when not in use. We use "stop" so that + // if the app resumes later, it will still remember what position to + // resume from. + player.stop(); + } } - Future playPlaylist(MPVPlayer player, CurrentPlaylist playlist) async { - try { - if (player.isRunning() && playlist.id != _currentPlaylistId) { - var playlistPath = "/tmp/playlist-${playlist.id}.txt"; - File file = File(playlistPath); - var newPlaylist = playlistToStr(playlist); + void _movePlaylistPositionBy(int pos) { + Playback playback = context.read(); + if (playback.currentTrack != null && playback.currentPlaylist != null) { + int index = playback.currentPlaylist!.trackIds + .indexOf(playback.currentTrack!.id!) + + pos; - if (!await file.exists()) { - await file.create(); - } - - await file.writeAsString(newPlaylist); - - await player.loadPlaylist(playlistPath); + var safeIndex = index > playback.currentPlaylist!.trackIds.length - 1 + ? 0 + : index < 0 + ? playback.currentPlaylist!.trackIds.length + : index; + Track? track = + playback.currentPlaylist!.tracks.asMap().containsKey(safeIndex) + ? playback.currentPlaylist!.tracks.elementAt(safeIndex) + : null; + if (track != null) { + playback.setCurrentTrack = track; setState(() { - _currentPlaylistId = playlist.id; - _shuffled = false; + _duration = null; }); } - } catch (e, stackTrace) { - print("[Player]: $e"); - print(stackTrace); + } + } + + Future _playTrack(Track currentTrack, Playback playback) async { + try { + if (currentTrack.id != _currentTrackId) { + if (currentTrack.uri != null) { + await player + .setAudioSource( + AudioSource.uri(Uri.parse(currentTrack.uri!)), + preload: true, + ) + .then((value) async { + setState(() { + _currentTrackId = currentTrack.id; + if (_duration != null) { + _duration = value; + } + }); + }); + } + var ytTrack = await toYoutubeTrack(currentTrack); + if (playback.setTrackUriById(currentTrack.id!, ytTrack.uri!)) { + await player + .setAudioSource(AudioSource.uri(Uri.parse(ytTrack.uri!))) + .then((value) { + setState(() { + _currentTrackId = currentTrack.id; + }); + }); + } + } + } catch (e, stack) { + print("[Player._playTrack()] $e"); + print(stack); } } @override Widget build(BuildContext context) { - MPVPlayer player = context.watch().player; - return Container( color: Theme.of(context).backgroundColor, child: Consumer( builder: (context, playback, widget) { - if (playback.currentPlaylist != null) { - playPlaylist(player, playback.currentPlaylist!); + if (playback.currentPlaylist != null && + playback.currentTrack != null) { + _playTrack(playback.currentTrack!, playback); } String? albumArt = playback.currentTrack?.album?.images?.last.url; @@ -223,33 +254,89 @@ class _PlayerState extends State { Flexible( flex: 3, child: PlayerControls( - player: player, + positionStream: player.positionStream, isPlaying: _isPlaying, - duration: _duration, + duration: _duration ?? Duration.zero, shuffled: _shuffled, - onShuffle: () { - if (!_shuffled) { - player.shuffle().then( - (value) => setState(() { - _shuffled = true; - }), - ); - } else { - player.unshuffle().then( - (value) => setState(() { - _shuffled = false; - }), - ); + onNext: () async { + try { + await player.pause(); + await player.seek(Duration.zero); + _movePlaylistPositionBy(1); + } catch (e, stack) { + print("[PlayerControls.onNext()] $e"); + print(stack); } }, - onStop: () { - setState(() { - _isPlaying = false; - _currentPlaylistId = null; - _duration = 0; - _shuffled = false; - }); - playback.reset(); + onPrevious: () async { + try { + await player.pause(); + await player.seek(Duration.zero); + _movePlaylistPositionBy(-1); + } catch (e, stack) { + print("[PlayerControls.onPrevious()] $e"); + print(stack); + } + }, + onPause: () async { + try { + await player.pause(); + } catch (e, stack) { + print("[PlayerControls.onPause()] $e"); + print(stack); + } + }, + onPlay: () async { + try { + await player.play(); + } catch (e, stack) { + print("[PlayerControls.onPlay()] $e"); + print(stack); + } + }, + onSeek: (value) async { + try { + await player.seek(Duration(seconds: value.toInt())); + } catch (e, stack) { + print("[PlayerControls.onSeek()] $e"); + print(stack); + } + }, + onShuffle: () async { + try { + if (!_shuffled) { + await player.setShuffleModeEnabled(true).then( + (value) => setState(() { + _shuffled = true; + }), + ); + } else { + await player.setShuffleModeEnabled(false).then( + (value) => setState(() { + _shuffled = false; + }), + ); + } + } catch (e, stack) { + print("[PlayerControls.onShuffle()] $e"); + print(stack); + } + }, + onStop: () async { + try { + await player.pause(); + await player.seek(Duration.zero); + setState(() { + _isPlaying = false; + _currentTrackId = null; + _duration = null; + _shuffled = false; + }); + playback.reset(); + } catch (e, stack) { + print("[PlayerControls.onStop()] $e"); + print(stack); + } }, ), ), @@ -289,12 +376,17 @@ class _PlayerState extends State { constraints: const BoxConstraints(maxWidth: 200), child: Slider.adaptive( value: _volume, - onChanged: (value) { - player.volume(value * 100).then((_) { - setState(() { - _volume = value; + onChanged: (value) async { + try { + await player.setVolume(value).then((_) { + setState(() { + _volume = value; + }); }); - }); + } catch (e, stack) { + print("[VolumeSlider.onChange()] $e"); + print(stack); + } }, ), ), diff --git a/lib/components/PlayerControls.dart b/lib/components/PlayerControls.dart index 7a872e9f..9d7bc6ca 100644 --- a/lib/components/PlayerControls.dart +++ b/lib/components/PlayerControls.dart @@ -1,22 +1,32 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:mpv_dart/mpv_dart.dart'; import 'package:spotube/helpers/zero-pad-num-str.dart'; class PlayerControls extends StatefulWidget { - final MPVPlayer player; + final Stream positionStream; final bool isPlaying; - final double duration; + final Duration duration; final bool shuffled; final Function? onStop; final Function? onShuffle; + final Function(double value)? onSeek; + final Function? onNext; + final Function? onPrevious; + final Function? onPlay; + final Function? onPause; const PlayerControls({ - required this.player, + required this.positionStream, required this.isPlaying, required this.duration, required this.shuffled, this.onShuffle, this.onStop, + this.onSeek, + this.onNext, + this.onPrevious, + this.onPlay, + this.onPause, Key? key, }) : super(key: key); @@ -25,64 +35,58 @@ class PlayerControls extends StatefulWidget { } class _PlayerControlsState extends State { - double currentPos = 0; + StreamSubscription? _timePositionListener; @override - void initState() { - super.initState(); - widget.player.on(MPVEvents.timeposition, null, (ev, context) { - widget.player.getPercentPosition().then((value) { - setState(() { - currentPos = value / 100; - }); - }); - }); - } - - @override - void dispose() { - widget.player.removeAllByEvent(MPVEvents.timeposition); + void dispose() async { + await _timePositionListener?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { - var totalDuration = Duration(seconds: widget.duration.toInt()); - var totalMinutes = zeroPadNumStr(totalDuration.inMinutes.remainder(60)); - var totalSeconds = zeroPadNumStr(totalDuration.inSeconds.remainder(60)); - - var currentDuration = - Duration(seconds: (widget.duration * currentPos).toInt()); - - var currentMinutes = zeroPadNumStr(currentDuration.inMinutes.remainder(60)); - var currentSeconds = zeroPadNumStr(currentDuration.inSeconds.remainder(60)); - return Container( constraints: const BoxConstraints(maxWidth: 700), child: Column( children: [ - Row( - children: [ - Expanded( - child: Slider.adaptive( - value: currentPos, - onChanged: (value) async { - try { - setState(() { - currentPos = value; - }); - await widget.player.goToPosition(value * widget.duration); - } catch (e) { - print("[PlayerControls]: $e"); - } - }, - ), - ), - Text( - "$currentMinutes:$currentSeconds/$totalMinutes:$totalSeconds", - ) - ], - ), + StreamBuilder( + stream: widget.positionStream, + builder: (context, snapshot) { + var totalMinutes = + zeroPadNumStr(widget.duration.inMinutes.remainder(60)); + var totalSeconds = + zeroPadNumStr(widget.duration.inSeconds.remainder(60)); + var currentMinutes = snapshot.hasData + ? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60)) + : "00"; + var currentSeconds = snapshot.hasData + ? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60)) + : "00"; + + var sliderMax = widget.duration.inSeconds; + var sliderValue = snapshot.data?.inSeconds ?? 0; + return Row( + children: [ + Expanded( + child: Slider.adaptive( + // cannot divide by zero + // there's an edge case for value being bigger + // than total duration. Keeping it resolved + value: (sliderMax == 0 || sliderValue > sliderMax) + ? 0 + : sliderValue / sliderMax, + onChanged: (value) {}, + onChangeEnd: (value) { + widget.onSeek?.call(value * sliderMax); + }, + ), + ), + Text( + "$currentMinutes:$currentSeconds/$totalMinutes:$totalSeconds", + ) + ], + ); + }), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -95,13 +99,8 @@ class _PlayerControlsState extends State { }), IconButton( icon: const Icon(Icons.skip_previous_rounded), - onPressed: () async { - bool moved = await widget.player.prev(); - if (moved) { - setState(() { - currentPos = 0; - }); - } + onPressed: () { + widget.onPrevious?.call(); }), IconButton( icon: Icon( @@ -109,30 +108,17 @@ class _PlayerControlsState extends State { ? Icons.pause_rounded : Icons.play_arrow_rounded, ), - onPressed: () async { + onPressed: () { widget.isPlaying - ? await widget.player.pause() - : await widget.player.play(); + ? widget.onPause?.call() + : widget.onPlay?.call(); }), IconButton( icon: const Icon(Icons.skip_next_rounded), - onPressed: () async { - bool moved = await widget.player.next(); - if (moved) { - setState(() { - currentPos = 0; - }); - } - }), + onPressed: () => widget.onNext?.call()), IconButton( icon: const Icon(Icons.stop_rounded), - onPressed: () async { - await widget.player.stop(); - widget.onStop?.call(); - setState(() { - currentPos = 0; - }); - }, + onPressed: () => widget.onStop?.call(), ) ], ) diff --git a/lib/components/PlaylistCard.dart b/lib/components/PlaylistCard.dart index 7c24daf4..ec1ae2b0 100644 --- a/lib/components/PlaylistCard.dart +++ b/lib/components/PlaylistCard.dart @@ -87,6 +87,7 @@ class _PlaylistCardState extends State { name: widget.playlist.name!, thumbnail: widget.playlist.images!.first.url!, ); + playback.setCurrentTrack = tracks.first; }, child: Icon( isPlaylistPlaying diff --git a/lib/components/PlaylistView.dart b/lib/components/PlaylistView.dart index 59e318a6..95d67b55 100644 --- a/lib/components/PlaylistView.dart +++ b/lib/components/PlaylistView.dart @@ -155,6 +155,7 @@ class _PlaylistViewState extends State { .images![0] .url!, ); + playback.setCurrentTrack = tracks.first; } } : null, diff --git a/lib/helpers/search-youtube.dart b/lib/helpers/search-youtube.dart new file mode 100644 index 00000000..f659e68d --- /dev/null +++ b/lib/helpers/search-youtube.dart @@ -0,0 +1,24 @@ +import 'package:spotify/spotify.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +YoutubeExplode youtube = YoutubeExplode(); +Future toYoutubeTrack(Track track) async { + var artistsName = track.artists?.map((ar) => ar.name).toList() ?? []; + String queryString = + "${artistsName.first} - ${track.name}${artistsName.length > 1 ? " feat. ${artistsName.sublist(1).join(" ")}" : ""}"; + + SearchList videos = await youtube.search.getVideos(queryString); + + List