670 lines
23 KiB
C#
670 lines
23 KiB
C#
using Discord;
|
|
using Discord.Audio;
|
|
using System;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using NLog;
|
|
using System.Linq;
|
|
using NadekoBot.Extensions;
|
|
using System.Diagnostics;
|
|
using NadekoBot.Common.Collections;
|
|
using NadekoBot.Modules.Music.Services;
|
|
using NadekoBot.Core.Services;
|
|
using NadekoBot.Core.Services.Database.Models;
|
|
|
|
namespace NadekoBot.Modules.Music.Common
|
|
{
|
|
public enum StreamState
|
|
{
|
|
Resolving,
|
|
Queued,
|
|
Playing,
|
|
Completed
|
|
}
|
|
public class MusicPlayer
|
|
{
|
|
private readonly Thread _player;
|
|
public IVoiceChannel VoiceChannel { get; private set; }
|
|
private readonly Logger _log;
|
|
|
|
private MusicQueue Queue { get; } = new MusicQueue();
|
|
|
|
public bool Exited { get; set; } = false;
|
|
public bool Stopped { get; private set; } = false;
|
|
public float Volume { get; private set; } = 1.0f;
|
|
public bool Paused => pauseTaskSource != null;
|
|
private TaskCompletionSource<bool> pauseTaskSource { get; set; } = null;
|
|
|
|
public string PrettyVolume => $"🔉 {(int)(Volume * 100)}%";
|
|
public string PrettyCurrentTime
|
|
{
|
|
get
|
|
{
|
|
var time = CurrentTime.ToString(@"mm\:ss");
|
|
var hrs = (int)CurrentTime.TotalHours;
|
|
|
|
if (hrs > 0)
|
|
return hrs + ":" + time;
|
|
else
|
|
return time;
|
|
}
|
|
}
|
|
public string PrettyFullTime => PrettyCurrentTime + " / " + (Queue.Current.Song?.PrettyTotalTime ?? "?");
|
|
private CancellationTokenSource SongCancelSource { get; set; }
|
|
public ITextChannel OutputTextChannel { get; set; }
|
|
public (int Index, SongInfo Current) Current
|
|
{
|
|
get
|
|
{
|
|
if (Stopped)
|
|
return (0, null);
|
|
return Queue.Current;
|
|
}
|
|
}
|
|
|
|
public bool RepeatCurrentSong { get; private set; }
|
|
public bool Shuffle { get; private set; }
|
|
public bool Autoplay { get; private set; }
|
|
public bool RepeatPlaylist { get; private set; } = false;
|
|
public uint MaxQueueSize
|
|
{
|
|
get => Queue.MaxQueueSize;
|
|
set { lock (locker) Queue.MaxQueueSize = value; }
|
|
}
|
|
private bool _fairPlay;
|
|
public bool FairPlay
|
|
{
|
|
get => _fairPlay;
|
|
set
|
|
{
|
|
if (value)
|
|
{
|
|
var cur = Queue.Current;
|
|
if (cur.Song != null)
|
|
RecentlyPlayedUsers.Add(cur.Song.QueuerName);
|
|
}
|
|
else
|
|
{
|
|
RecentlyPlayedUsers.Clear();
|
|
}
|
|
|
|
_fairPlay = value;
|
|
}
|
|
}
|
|
public bool AutoDelete { get; set; }
|
|
public uint MaxPlaytimeSeconds { get; set; }
|
|
|
|
|
|
const int _frameBytes = 3840;
|
|
const float _miliseconds = 20.0f;
|
|
public TimeSpan CurrentTime => TimeSpan.FromSeconds(_bytesSent / (float)_frameBytes / (1000 / _miliseconds));
|
|
|
|
private int _bytesSent = 0;
|
|
|
|
private IAudioClient _audioClient;
|
|
private readonly object locker = new object();
|
|
private MusicService _musicService;
|
|
|
|
#region events
|
|
public event Action<MusicPlayer, (int Index, SongInfo Song)> OnStarted;
|
|
public event Action<MusicPlayer, SongInfo> OnCompleted;
|
|
public event Action<MusicPlayer, bool> OnPauseChanged;
|
|
#endregion
|
|
|
|
|
|
private bool manualSkip = false;
|
|
private bool manualIndex = false;
|
|
private bool newVoiceChannel = false;
|
|
private readonly IGoogleApiService _google;
|
|
|
|
private bool cancel = false;
|
|
|
|
private ConcurrentHashSet<string> RecentlyPlayedUsers { get; } = new ConcurrentHashSet<string>();
|
|
public TimeSpan TotalPlaytime
|
|
{
|
|
get
|
|
{
|
|
var songs = Queue.ToArray().Songs;
|
|
return songs.Any(s => s.TotalTime == TimeSpan.MaxValue)
|
|
? TimeSpan.MaxValue
|
|
: new TimeSpan(songs.Sum(s => s.TotalTime.Ticks));
|
|
}
|
|
}
|
|
|
|
|
|
public MusicPlayer(MusicService musicService, IGoogleApiService google, IVoiceChannel vch, ITextChannel output, float volume)
|
|
{
|
|
_log = LogManager.GetCurrentClassLogger();
|
|
this.Volume = volume;
|
|
this.VoiceChannel = vch;
|
|
this.SongCancelSource = new CancellationTokenSource();
|
|
this.OutputTextChannel = output;
|
|
this._musicService = musicService;
|
|
this._google = google;
|
|
|
|
_log.Info("Initialized");
|
|
|
|
_player = new Thread(new ThreadStart(PlayerLoop));
|
|
_player.Start();
|
|
_log.Info("Loop started");
|
|
}
|
|
|
|
private async void PlayerLoop()
|
|
{
|
|
while (!Exited)
|
|
{
|
|
_bytesSent = 0;
|
|
cancel = false;
|
|
CancellationToken cancelToken;
|
|
(int Index, SongInfo Song) data;
|
|
lock (locker)
|
|
{
|
|
data = Queue.Current;
|
|
cancelToken = SongCancelSource.Token;
|
|
manualSkip = false;
|
|
manualIndex = false;
|
|
}
|
|
if (data.Song != null)
|
|
{
|
|
_log.Info("Starting");
|
|
AudioOutStream pcm = null;
|
|
SongBuffer b = null;
|
|
try
|
|
{
|
|
b = new SongBuffer(await data.Song.Uri(), "", data.Song.ProviderType == MusicType.Local);
|
|
//_log.Info("Created buffer, buffering...");
|
|
|
|
//var bufferTask = b.StartBuffering(cancelToken);
|
|
//var timeout = Task.Delay(10000);
|
|
//if (Task.WhenAny(bufferTask, timeout) == timeout)
|
|
//{
|
|
// _log.Info("Buffering failed due to a timeout.");
|
|
// continue;
|
|
//}
|
|
//else if (!bufferTask.Result)
|
|
//{
|
|
// _log.Info("Buffering failed due to a cancel or error.");
|
|
// continue;
|
|
//}
|
|
//_log.Info("Buffered. Getting audio client...");
|
|
var ac = await GetAudioClient();
|
|
_log.Info("Got Audio client");
|
|
if (ac == null)
|
|
{
|
|
_log.Info("Can't join");
|
|
await Task.Delay(900, cancelToken);
|
|
// just wait some time, maybe bot doesn't even have perms to join that voice channel,
|
|
// i don't want to spam connection attempts
|
|
continue;
|
|
}
|
|
pcm = ac.CreatePCMStream(AudioApplication.Music, bufferMillis: 500);
|
|
_log.Info("Created pcm stream");
|
|
OnStarted?.Invoke(this, data);
|
|
|
|
byte[] buffer = new byte[3840];
|
|
int bytesRead = 0;
|
|
|
|
while ((bytesRead = b.Read(buffer, 0, buffer.Length)) > 0
|
|
&& (MaxPlaytimeSeconds <= 0 || MaxPlaytimeSeconds >= CurrentTime.TotalSeconds))
|
|
{
|
|
AdjustVolume(buffer, Volume);
|
|
await pcm.WriteAsync(buffer, 0, bytesRead, cancelToken).ConfigureAwait(false);
|
|
unchecked { _bytesSent += bytesRead; }
|
|
|
|
await (pauseTaskSource?.Task ?? Task.CompletedTask);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
_log.Info("Song Canceled");
|
|
cancel = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.Warn(ex);
|
|
}
|
|
finally
|
|
{
|
|
if (pcm != null)
|
|
{
|
|
// flush is known to get stuck from time to time,
|
|
// just skip flushing if it takes more than 1 second
|
|
var flushCancel = new CancellationTokenSource();
|
|
var flushToken = flushCancel.Token;
|
|
var flushDelay = Task.Delay(1000, flushToken);
|
|
await Task.WhenAny(flushDelay, pcm.FlushAsync(flushToken));
|
|
flushCancel.Cancel();
|
|
pcm.Dispose();
|
|
}
|
|
|
|
if (b != null)
|
|
b.Dispose();
|
|
|
|
OnCompleted?.Invoke(this, data.Song);
|
|
|
|
if (_bytesSent == 0 && !cancel)
|
|
{
|
|
lock (locker)
|
|
Queue.RemoveSong(data.Song);
|
|
_log.Info("Song removed because it can't play");
|
|
}
|
|
}
|
|
try
|
|
{
|
|
//if repeating current song, just ignore other settings,
|
|
// and play this song again (don't change the index)
|
|
// ignore rcs if song is manually skipped
|
|
|
|
int queueCount;
|
|
bool stopped;
|
|
int currentIndex;
|
|
lock (locker)
|
|
{
|
|
queueCount = Queue.Count;
|
|
stopped = Stopped;
|
|
currentIndex = Queue.CurrentIndex;
|
|
}
|
|
|
|
if (AutoDelete && !RepeatCurrentSong && !RepeatPlaylist && data.Song != null)
|
|
{
|
|
Queue.RemoveSong(data.Song);
|
|
}
|
|
|
|
if (!manualIndex && (!RepeatCurrentSong || manualSkip))
|
|
{
|
|
if (Shuffle)
|
|
{
|
|
_log.Info("Random song");
|
|
Queue.Random(); //if shuffle is set, set current song index to a random number
|
|
}
|
|
else
|
|
{
|
|
//if last song, and autoplay is enabled, and if it's a youtube song
|
|
// do autplay magix
|
|
if (queueCount - 1 == data.Index && Autoplay && data.Song?.ProviderType == MusicType.YouTube)
|
|
{
|
|
try
|
|
{
|
|
_log.Info("Loading related song");
|
|
await _musicService.TryQueueRelatedSongAsync(data.Song, OutputTextChannel, VoiceChannel);
|
|
if(!AutoDelete)
|
|
Queue.Next();
|
|
}
|
|
catch
|
|
{
|
|
_log.Info("Loading related song failed.");
|
|
}
|
|
}
|
|
else if (FairPlay)
|
|
{
|
|
lock (locker)
|
|
{
|
|
_log.Info("Next fair song");
|
|
var q = Queue.ToArray().Songs.Shuffle().ToArray();
|
|
|
|
bool found = false;
|
|
for (var i = 0; i < q.Length; i++) //first try to find a queuer who didn't have their song played recently
|
|
{
|
|
var item = q[i];
|
|
if (RecentlyPlayedUsers.Add(item.QueuerName)) // if it's found, set current song to that index
|
|
{
|
|
Queue.CurrentIndex = i;
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!found) //if it's not
|
|
{
|
|
RecentlyPlayedUsers.Clear(); //clear all recently played users (that means everyone from the playlist has had their song played)
|
|
Queue.Random(); //go to a random song (to prevent looping on the first few songs)
|
|
var cur = Current;
|
|
if (cur.Current != null) // add newely scheduled song's queuer to the recently played list
|
|
RecentlyPlayedUsers.Add(cur.Current.QueuerName);
|
|
}
|
|
}
|
|
}
|
|
else if (queueCount - 1 == data.Index && !RepeatPlaylist && !manualSkip)
|
|
{
|
|
_log.Info("Stopping because repeatplaylist is disabled");
|
|
lock (locker)
|
|
{
|
|
Stop();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_log.Info("Next song");
|
|
lock (locker)
|
|
{
|
|
if (!Stopped)
|
|
if(!AutoDelete)
|
|
Queue.Next();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.Error(ex);
|
|
}
|
|
}
|
|
do
|
|
{
|
|
await Task.Delay(500);
|
|
}
|
|
while ((Queue.Count == 0 || Stopped) && !Exited);
|
|
}
|
|
}
|
|
|
|
private async Task<IAudioClient> GetAudioClient(bool reconnect = false)
|
|
{
|
|
if (_audioClient == null ||
|
|
_audioClient.ConnectionState != ConnectionState.Connected ||
|
|
reconnect ||
|
|
newVoiceChannel)
|
|
try
|
|
{
|
|
try
|
|
{
|
|
var t = _audioClient?.StopAsync();
|
|
if (t != null)
|
|
{
|
|
|
|
_log.Info("Stopping audio client");
|
|
await t;
|
|
|
|
_log.Info("Disposing audio client");
|
|
_audioClient.Dispose();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
newVoiceChannel = false;
|
|
|
|
_log.Info("Get current user");
|
|
var curUser = await VoiceChannel.Guild.GetCurrentUserAsync();
|
|
if (curUser.VoiceChannel != null)
|
|
{
|
|
_log.Info("Connecting");
|
|
var ac = await VoiceChannel.ConnectAsync();
|
|
_log.Info("Connected, stopping");
|
|
await ac.StopAsync();
|
|
_log.Info("Disconnected");
|
|
await Task.Delay(1000);
|
|
}
|
|
_log.Info("Connecting");
|
|
_audioClient = await VoiceChannel.ConnectAsync();
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
return _audioClient;
|
|
}
|
|
|
|
public int Enqueue(SongInfo song)
|
|
{
|
|
lock (locker)
|
|
{
|
|
if (Exited)
|
|
return -1;
|
|
Queue.Add(song);
|
|
return Queue.Count - 1;
|
|
}
|
|
}
|
|
|
|
public int EnqueueNext(SongInfo song)
|
|
{
|
|
lock (locker)
|
|
{
|
|
if (Exited)
|
|
return -1;
|
|
return Queue.AddNext(song);
|
|
}
|
|
}
|
|
|
|
public void SetIndex(int index)
|
|
{
|
|
if (index < 0)
|
|
throw new ArgumentOutOfRangeException(nameof(index));
|
|
lock (locker)
|
|
{
|
|
if (Exited)
|
|
return;
|
|
if (AutoDelete && index >= Queue.CurrentIndex && index > 0)
|
|
index--;
|
|
Queue.CurrentIndex = index;
|
|
manualIndex = true;
|
|
Stopped = false;
|
|
CancelCurrentSong();
|
|
}
|
|
}
|
|
|
|
public void Next(int skipCount = 1)
|
|
{
|
|
lock (locker)
|
|
{
|
|
if (Exited)
|
|
return;
|
|
manualSkip = true;
|
|
// if player is stopped, and user uses .n, it should play current song.
|
|
// It's a bit weird, but that's the least annoying solution
|
|
if (!Stopped)
|
|
if (!RepeatPlaylist && Queue.IsLast()) // if it's the last song in the queue, and repeat playlist is disabled
|
|
{ //stop the queue
|
|
Stop();
|
|
return;
|
|
}
|
|
else
|
|
Queue.Next(skipCount - 1);
|
|
else
|
|
Queue.CurrentIndex = 0;
|
|
Stopped = false;
|
|
CancelCurrentSong();
|
|
Unpause();
|
|
}
|
|
}
|
|
|
|
public void Stop(bool clearQueue = false)
|
|
{
|
|
lock (locker)
|
|
{
|
|
Stopped = true;
|
|
//Queue.ResetCurrent();
|
|
if (clearQueue)
|
|
Queue.Clear();
|
|
Unpause();
|
|
CancelCurrentSong();
|
|
}
|
|
}
|
|
|
|
private void Unpause()
|
|
{
|
|
lock (locker)
|
|
{
|
|
if (pauseTaskSource != null)
|
|
{
|
|
pauseTaskSource.TrySetResult(true);
|
|
pauseTaskSource = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
public void TogglePause()
|
|
{
|
|
lock (locker)
|
|
{
|
|
if (pauseTaskSource == null)
|
|
pauseTaskSource = new TaskCompletionSource<bool>();
|
|
else
|
|
{
|
|
Unpause();
|
|
}
|
|
}
|
|
OnPauseChanged?.Invoke(this, pauseTaskSource != null);
|
|
}
|
|
|
|
public void SetVolume(int volume)
|
|
{
|
|
if (volume < 0 || volume > 100)
|
|
throw new ArgumentOutOfRangeException(nameof(volume));
|
|
lock (locker)
|
|
{
|
|
Volume = ((float)volume) / 100;
|
|
}
|
|
}
|
|
|
|
public SongInfo RemoveAt(int index)
|
|
{
|
|
lock (locker)
|
|
{
|
|
var cur = Queue.Current;
|
|
var toReturn = Queue.RemoveAt(index);
|
|
if (cur.Index == index)
|
|
Next();
|
|
return toReturn;
|
|
}
|
|
}
|
|
|
|
private void CancelCurrentSong()
|
|
{
|
|
lock (locker)
|
|
{
|
|
var cs = SongCancelSource;
|
|
SongCancelSource = new CancellationTokenSource();
|
|
cs.Cancel();
|
|
}
|
|
}
|
|
|
|
public void ClearQueue()
|
|
{
|
|
lock (locker)
|
|
{
|
|
Queue.Clear();
|
|
}
|
|
}
|
|
|
|
public (int CurrentIndex, SongInfo[] Songs) QueueArray()
|
|
{
|
|
lock (locker)
|
|
return Queue.ToArray();
|
|
}
|
|
|
|
//aidiakapi ftw
|
|
public static unsafe byte[] AdjustVolume(byte[] audioSamples, float volume)
|
|
{
|
|
if (Math.Abs(volume - 1f) < 0.0001f) return audioSamples;
|
|
|
|
// 16-bit precision for the multiplication
|
|
var volumeFixed = (int)Math.Round(volume * 65536d);
|
|
|
|
var count = audioSamples.Length / 2;
|
|
|
|
fixed (byte* srcBytes = audioSamples)
|
|
{
|
|
var src = (short*)srcBytes;
|
|
|
|
for (var i = count; i != 0; i--, src++)
|
|
*src = (short)(((*src) * volumeFixed) >> 16);
|
|
}
|
|
|
|
return audioSamples;
|
|
}
|
|
|
|
public bool ToggleRepeatSong()
|
|
{
|
|
lock (locker)
|
|
{
|
|
return RepeatCurrentSong = !RepeatCurrentSong;
|
|
}
|
|
}
|
|
|
|
public async Task Destroy()
|
|
{
|
|
_log.Info("Destroying");
|
|
lock (locker)
|
|
{
|
|
Stop();
|
|
Exited = true;
|
|
Unpause();
|
|
|
|
OnCompleted = null;
|
|
OnPauseChanged = null;
|
|
OnStarted = null;
|
|
}
|
|
var ac = _audioClient;
|
|
if (ac != null)
|
|
await ac.StopAsync();
|
|
}
|
|
|
|
public bool ToggleShuffle()
|
|
{
|
|
lock (locker)
|
|
{
|
|
return Shuffle = !Shuffle;
|
|
}
|
|
}
|
|
|
|
public bool ToggleAutoplay()
|
|
{
|
|
lock (locker)
|
|
{
|
|
return Autoplay = !Autoplay;
|
|
}
|
|
}
|
|
|
|
public bool ToggleRepeatPlaylist()
|
|
{
|
|
lock (locker)
|
|
{
|
|
return RepeatPlaylist = !RepeatPlaylist;
|
|
}
|
|
}
|
|
|
|
public async Task SetVoiceChannel(IVoiceChannel vch)
|
|
{
|
|
lock (locker)
|
|
{
|
|
if (Exited)
|
|
return;
|
|
VoiceChannel = vch;
|
|
}
|
|
_audioClient = await vch.ConnectAsync();
|
|
}
|
|
|
|
public async Task UpdateSongDurationsAsync()
|
|
{
|
|
var sw = Stopwatch.StartNew();
|
|
var (_, songs) = Queue.ToArray();
|
|
var toUpdate = songs
|
|
.Where(x => x.ProviderType == MusicType.YouTube
|
|
&& x.TotalTime == TimeSpan.Zero);
|
|
|
|
var vIds = toUpdate.Select(x => x.VideoId);
|
|
|
|
sw.Stop();
|
|
_log.Info(sw.Elapsed.TotalSeconds);
|
|
if (!vIds.Any())
|
|
return;
|
|
|
|
var durations = await _google.GetVideoDurationsAsync(vIds);
|
|
|
|
foreach (var x in toUpdate)
|
|
{
|
|
if (durations.TryGetValue(x.VideoId, out var dur))
|
|
x.TotalTime = dur;
|
|
}
|
|
}
|
|
|
|
public SongInfo MoveSong(int n1, int n2)
|
|
=> Queue.MoveSong(n1, n2);
|
|
|
|
//// this should be written better
|
|
//public TimeSpan TotalPlaytime =>
|
|
// _playlist.Any(s => s.TotalTime == TimeSpan.MaxValue) ?
|
|
// TimeSpan.MaxValue :
|
|
// new TimeSpan(_playlist.Sum(s => s.TotalTime.Ticks));
|
|
}
|
|
} |