3
\$\begingroup\$

This is a continuation of Part 1.

In this question I will present you with a single class which is the most crucial component of the whole system, it's responsible for firing notifications whenever a subtitle must be shown or hidden. The notifications are later consumed by a ViewModel which than notifies it's View, but I will keep that part for the next question.

public sealed class SubtitleHandler : ICustomObservable<MediaEventArgs<SubtitlesEventType>>
{
    private CancellationTokenSource _cancellationToken = new CancellationTokenSource();
    private readonly AsyncManualResetEvent _manualResetEventAsync = new AsyncManualResetEvent();

    private CircularList<SubtitleSegment> _subtitles;
    private readonly HashSet<SubtitleSegment> _currentlyShownSubtitles = new HashSet<SubtitleSegment>();

    private readonly IntermissionTimer _subtitleDisplayTracker;

    private readonly Encoding _encoding;

    private IMediaControlsViewModel _providerControls;

    private MediaObservableMap<SubscriptionEventType> _subtitlesProviderMap;
    private MediaObservableMap<MediaElementEventType> _mediaElementProviderMap;
    private MediaObservableMap<MediaControlEventType> _mediaControlsProviderMap;
    private ObservableMap<bool, FileManagerEventArgs<FileInformation>> _fileManagerMap;

    private readonly MultipleProvidersCache _multipleProvidersCache = new MultipleProvidersCache();

    private readonly SingleComparer<TimeSpan> _resyncComparer =
        new SingleComparer<TimeSpan>((span, subtitleInterval) => span > subtitleInterval);

    private static readonly object _padLock = new object();

    public SubtitleHandler([NotNull] Encoding encoding, [NotNull] ISubtitlesViewModel provider)
    {
        if (provider == null) throw new ArgumentNullException(nameof(provider));

        _encoding = encoding ?? throw new ArgumentNullException(nameof(encoding));
        _subtitleDisplayTracker = new IntermissionTimer();
        _subtitleDisplayTracker.Tick += SubtitleDisplayTracker_OnTick;
        InitializeMaps();
        ConfigureNotifications(provider);
    }

    private void InitializeMaps()
    {
        _subtitlesProviderMap = new MediaObservableMap<SubscriptionEventType>((map, args) => args.EventType)
        {
            {SubscriptionEventType.MediaAttached,
                (sender, args) => AttachMediaElement((IMediaElementViewModel) args.AdditionalInfo)
            },
            {SubscriptionEventType.MediaDetached, (sender, args) => DetachMediaElement()},
        };

        _mediaElementProviderMap = new MediaObservableMap<MediaElementEventType>((map, args) => args.EventType)
        {
            {MediaElementEventType.Started, (sender, args) => TryBegin((MediaFileInformation) args.AdditionalInfo)},
            {MediaElementEventType.Ended, (sender, args) => Stop()}
        };

        _mediaControlsProviderMap = new MediaObservableMap<MediaControlEventType>(
            (map, args) => args.EventType, 
            (map, type) => _subtitles != null)
        {
            {MediaControlEventType.FastForwardClick, Skip},
            {MediaControlEventType.RewindClick, Skip},
            {MediaControlEventType.PauseClick, (sender, args) => Pause()},
            {MediaControlEventType.PlayClick, (sender, args) => Resume()},
            {MediaControlEventType.StopClick, (sender, args) => Stop()},
            {MediaControlEventType.PositionSliderDragStarted, (sender, args) =>
                {
                    ForceHidingAllSubtitles();
                    Pause();
                }
            },
            {
                MediaControlEventType.PositionSliderDragCompleted,
                async (sender, args) => await ResyncAndResumeAsync((TimeSpan) args.AdditionalInfo)
            }
        };

        _fileManagerMap = new ObservableMap<bool, FileManagerEventArgs<FileInformation>>(
            (map, args) => args.SelectedFiles.FirstOrDefault() != null)
        {
            {
                true, (sender, args) => Begin(args.SelectedFiles.First(),
                    _providerControls?.CurrentMediaPlayer.MediaElement.Position ?? default(TimeSpan))
            }
        };
    }

    private async void Skip(object sender, MediaEventArgs<MediaControlEventType> args)
    {
        var time = (TimeSpan) args.AdditionalInfo;
        if (_subtitleDisplayTracker.IsEnabled)
        {
            await ResyncAndResumeAsync(time);
        }
        else
        {
            Resync(time);
        }
    }

    private void ConfigureNotifications(ISubtitlesViewModel subtitlesProvider)
    {
        _multipleProvidersCache.AddProvider(subtitlesProvider, _subtitlesProviderMap);
        _multipleProvidersCache.AddProvider(FileManager<FileInformation>.Instance, _fileManagerMap);
    }

    private void AttachMediaElement(IMediaElementViewModel mediaElementProvider)
    {
        _providerControls = mediaElementProvider.CurentControls;
        _multipleProvidersCache.AddProvider(mediaElementProvider, _mediaElementProviderMap);
        _multipleProvidersCache.AddProvider(_providerControls, _mediaControlsProviderMap);
    }

    private void DetachMediaElement()
    {
        _multipleProvidersCache.RemoveProvider<IMediaElementViewModel, MediaEventArgs<MediaElementEventType>>();
        _multipleProvidersCache.RemoveProvider<IMediaControlsViewModel, MediaEventArgs<MediaControlEventType>>();
        _providerControls = null;
        ForceHidingAllSubtitles();
    }

    private async void SubtitleDisplayTracker_OnTick(object sender, EventArgs e)
    {
        var lastSubtitle = _subtitles.Current;
        _subtitles.MoveNext();
        _subtitleDisplayTracker.Interval = _subtitles.Current.Interval.Start -
                                           lastSubtitle.Interval.Start;
        await ResyncWithPlayerAsync(lastSubtitle.Interval);
        await ShowSubtitleAsync(lastSubtitle, lastSubtitle.Interval.Duration);
    }

    private async Task ResyncWithPlayerAsync(SubtitleInterval currentInterval)
    {
        var result = currentInterval.Start.Subtract(_providerControls.CurrentMediaPlayer.MediaElement.Position);
        var tolerance = TimeSpan.FromMilliseconds(10);
        if (result > tolerance)
        {
            //current is ahead
            await Task.Delay(result, _cancellationToken.Token).ContinueWith(_ => { });
        }
    }

    private void TryBegin(MediaFileInformation file)
    {
        if (Settings.DetectSubtitlesAutomatically)
        {
            var localSubs = SubtitleDetector.DetectSubtitles(file, Settings.PreferedSubtitleLanguage);
            if (localSubs != null)
            {
                Begin(localSubs, TimeSpan.Zero);
            }
        }
    }

    private void Begin(IFileInformation subtitleFile, TimeSpan startTime)
    {
        if (_subtitles != null)
        {
            _subtitleDisplayTracker.Stop();
        }
        _subtitles = new SubtitleReader(_encoding).ExtractSubtitles(subtitleFile.Uri.OriginalString);
        Resync(startTime);
        _cancellationToken = new CancellationTokenSource();
        _manualResetEventAsync.Set();
        _subtitleDisplayTracker.Start();
    }

    private void Pause()
    {
        _subtitleDisplayTracker.Pause();
        _manualResetEventAsync.Reset();
        _cancellationToken.Cancel();
    }

    private void Resume()
    {
        _manualResetEventAsync.Set();
        _cancellationToken = new CancellationTokenSource();
        _subtitleDisplayTracker.Resume();
    }

    private void Resync(TimeSpan time)
    {
        ForceHidingAllSubtitles();
        _resyncComparer.TargetValue = time;
        var targetIndex =
            _subtitles.DuplicateBinarySearch(_resyncComparer, segment => segment.Interval.End);
        var targetSubs = _subtitles[targetIndex];

        _subtitles.SetCurrent(targetIndex);
        if (time > targetSubs.Interval.Start)
        {
            _subtitles.SetCurrent(targetIndex + 1);
#pragma warning disable 4014
            ShowSubtitleAsync(targetSubs, targetSubs.Interval.End.Subtract(time));
#pragma warning restore 4014
        }
        _subtitleDisplayTracker.Interval = _subtitles.Current.Interval.Start.Subtract(time);
    }

    private async Task ResyncAndResumeAsync(TimeSpan span)
    {
        _cancellationToken = new CancellationTokenSource();
        await Task.Factory.StartNew(() =>
                {
                    lock (_padLock)
                    {
                        Resync(span);
                    }
                },
                _cancellationToken.Token,
                TaskCreationOptions.RunContinuationsAsynchronously,
                TaskScheduler.FromCurrentSynchronizationContext())
            .ContinueWith(task =>
            {
                if (task.Status == TaskStatus.RanToCompletion)
                {
                    Resume();
                }
            }, TaskScheduler.FromCurrentSynchronizationContext());
    }

    private void Stop()
    {
        _subtitleDisplayTracker.Stop();
        Resync(TimeSpan.Zero);
    }

    private async Task ShowSubtitleAsync(SubtitleSegment subtitle, TimeSpan duration)
    {
        OnDisplaySubtitle(subtitle);
        _currentlyShownSubtitles.Add(subtitle);

        await ScheduleSubtitleHidingAsync(subtitle, duration);
    }

    private async Task ScheduleSubtitleHidingAsync(SubtitleSegment subtitle, TimeSpan span)
    {
        var startTime = DateTime.Now;

        await Task.Delay(span, _cancellationToken.Token)
            .ContinueWith(task =>
            {
                if (task.Status == TaskStatus.RanToCompletion)
                {
                    OnHideSubtitle(subtitle);
                    _currentlyShownSubtitles.Remove(subtitle);
                }
            }, TaskScheduler.FromCurrentSynchronizationContext());

        var leftTime = span.Subtract(TimeSpan.FromTicks(DateTime.Now.Subtract(startTime).Ticks));
        if (leftTime > TimeSpan.FromMilliseconds(1))
        {
            await _manualResetEventAsync.WaitAsync();
            await ScheduleSubtitleHidingAsync(subtitle, leftTime);
        }
    }

    private void ForceHidingAllSubtitles()
    {
        if (_currentlyShownSubtitles.Count == 0)
        {
            return;
        }
        _cancellationToken.Cancel(false);
        _manualResetEventAsync.Reset();
        lock (_padLock)
        {
            foreach (var currentlyShownSubtitle in _currentlyShownSubtitles)
            {
                OnHideSubtitle(currentlyShownSubtitle);
            }
            _currentlyShownSubtitles.Clear();
        }
    }

    private void OnHideSubtitle(SubtitleSegment subtitle)
    {
        OnNotify(SubtitlesEventType.Hide, subtitle);
    }

    private void OnDisplaySubtitle(SubtitleSegment subtitle)
    {
        OnNotify(SubtitlesEventType.Display, subtitle);
    }

    #region Implementation of ICustomObservable<MediaEventArgs<SubtitlesEventType>>

    public event EventHandler<MediaEventArgs<SubtitlesEventType>> Notify;

    private void OnNotify(SubtitlesEventType eventType, object additionalInfo = null)
    {
        Notify?.Invoke(this, new MediaEventArgs<SubtitlesEventType>(eventType, additionalInfo));
    }

    #endregion
}

The media player has functionality for pausing/resuming, fast forward/rewind, dragging the slider, jumping through the slider and stopping the file, if there is a subtitle track running, the SubtitleHandler must react correspondingly.

There is an internal IntermissionTimer which keeps track of when the next subtitle should be shown, it allows Pause/Resume behaviour and it's implemented as follows:

public class IntermissionTimer : DispatcherTimer
{
    protected MulticastDelegate _tickEventHandlers;
    protected MulticastDelegate TickEventHandlers =>
        _tickEventHandlers ?? (_tickEventHandlers = (MulticastDelegate)typeof(DispatcherTimer)
            .GetField("Tick", BindingFlags.Instance | BindingFlags.NonPublic)
            ?.GetValue(this));

    protected CancellationTokenSource _cancellationToken;

    protected readonly Stopwatch _internalTickCounter;
    protected Stopwatch _intervalTickedTime;

    public TimeSpan Elapsed => _internalTickCounter.Elapsed;

    public new TimeSpan Interval
    {
        get => base.Interval;
        set
        {
            base.Interval = value;
            _cancellationToken.Cancel();
            _cancellationToken = new CancellationTokenSource();
            _intervalTickedTime.Reset();
            if (_internalTickCounter.IsRunning)
            {
                Start();
            }
        }
    }

    public IntermissionTimer()
    {
        _cancellationToken = new CancellationTokenSource();
        _internalTickCounter = new Stopwatch();
        _intervalTickedTime = new Stopwatch();

        Tick += IntermissionTimer_Tick;
    }

    protected virtual void IntermissionTimer_Tick(object sender, EventArgs e)
    {
        if (sender == this)
        {
            _intervalTickedTime = Stopwatch.StartNew();
        }
    }

    public new virtual void Start()
    {
        base.Start();
        _internalTickCounter.Start();
        _intervalTickedTime = Stopwatch.StartNew();
    }

    public new virtual void Stop()
    {
        base.Stop();
        _internalTickCounter.Reset();
        _intervalTickedTime.Reset();
        _cancellationToken.Cancel();
    }

    public virtual void Pause()
    {
        _cancellationToken.Cancel();
        _cancellationToken = new CancellationTokenSource();
        base.Stop();
        _internalTickCounter.Stop();
        _intervalTickedTime.Stop();
    }

    public virtual void Resume()
    {
        if (!_internalTickCounter.IsRunning)
        {
            Task.Factory.StartNew(async () =>
            {
                _internalTickCounter.Start();
                _intervalTickedTime.Start();
                var scheduledIntermissionTime =
                    Math.Abs((int) (Interval - _intervalTickedTime.Elapsed).TotalMilliseconds);
                await Task.Delay(scheduledIntermissionTime, _cancellationToken.Token)
                    .ContinueWith(task =>
                    {
                        if (task.Status == TaskStatus.RanToCompletion)
                        {
                            foreach (var handler in TickEventHandlers.GetInvocationList())
                            {
                                handler.Method.Invoke(handler.Target, new object[] {this, EventArgs.Empty});
                            }
                            Start();
                        }
                    }, TaskScheduler.Default);
                _cancellationToken = new CancellationTokenSource();
            }, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.Default);
        }
    }
}

Notifications when a specific action has occurred (pause, resume, etc.) are handled by the MultipleProvidersCache class and are being sent by multiple providers:

internal class MultipleProvidersCache
{
    protected readonly ProviderActionCache<EventHandler<object>> _notificationsCache =
        new ProviderActionCache<EventHandler<object>>();

    protected readonly ProviderActionCache<Action> _providerUnsubscriberCache = new ProviderActionCache<Action>();

    internal virtual void AddProvider<T, TArgs>(
        [NotNull] ICustomObservable<TArgs> provider,
        [NotNull] ObservableMap<T, TArgs> map)
        where TArgs : EventArgs
    {
        if (provider == null) throw new ArgumentNullException(nameof(provider));
        if (map == null) throw new ArgumentNullException(nameof(map));

        var providerType = provider.GetType();
        _notificationsCache.Add<TArgs>(providerType, (sender, args) =>
        {
            var unboxedArgs = (TArgs) args;
            if (map.TryGetHandler(unboxedArgs, out var handler))
            {
                handler.Invoke(sender, unboxedArgs);
            }
        });
        void NotifierDelegate(object sender, TArgs args) => Provider_OnNotify(sender, args, providerType);
        provider.Notify += NotifierDelegate;
        _providerUnsubscriberCache.Add<TArgs>(providerType, () => provider.Notify -= NotifierDelegate);
    }

    internal virtual bool RemoveProvider<TProvider, TArgs>()
        where TArgs : EventArgs
        where TProvider : ICustomObservable<TArgs>
    {
        return RemoveProviderImpl<TArgs>(typeof(TProvider));
    }

    protected virtual bool RemoveProviderImpl<TArgs>([NotNull] Type providerType)
        where TArgs : EventArgs
    {
        if (providerType == null) throw new ArgumentNullException(nameof(providerType));

        if (_notificationsCache.RemoveAction<TArgs>(providerType))
        {
            _providerUnsubscriberCache.GetAction<TArgs>(providerType).Invoke();
            _providerUnsubscriberCache.RemoveAction<TArgs>(providerType);
            return true;
        }
        return false;
    }

    protected virtual void Provider_OnNotify<TArgs>(object sender, TArgs e, Type providerType)
        where TArgs : EventArgs
    {
        if (_notificationsCache.TryGetAction<TArgs>(providerType, out var action))
        {
            action.Invoke(sender, e);
        }
    }

    protected sealed class ProviderActionCache<TAction> : IEnumerable<KeyValuePair<Type, Dictionary<Type, TAction>>>
    {
        private readonly IDictionary<Type, Dictionary<Type, TAction>> _dictionary;

        internal ProviderActionCache()
        {
            _dictionary = new Dictionary<Type, Dictionary<Type, TAction>>();
        }

        internal void Add<TArgs>([NotNull] Type key, [NotNull] TAction value)
            where TArgs : EventArgs
        {
            if (key == null) throw new ArgumentNullException(nameof(key));
            if (value == null) throw new ArgumentNullException(nameof(value));

            if (_dictionary.TryGetValue(key, out var values))
            {
                values.Add(typeof(TArgs), value);
            }
            else
            {
                _dictionary.Add(key, new Dictionary<Type, TAction> { [typeof(TArgs)] = value });
            }
        }

        internal bool RemoveProvider([NotNull] Type providerType)
        {
            if (providerType == null) throw new ArgumentNullException(nameof(providerType));

            return _dictionary.Remove(providerType);
        }

        internal bool RemoveAction<TArgs>([NotNull] Type providerType)
            where TArgs : EventArgs
        {
            if (providerType == null) throw new ArgumentNullException(nameof(providerType));

            return _dictionary.TryGetValue(providerType, out var values) && values.Remove(typeof(TArgs));
        }

        internal bool TryGetProvider([NotNull] Type providerType, out Dictionary<Type, TAction> values)
        {
            if (providerType == null) throw new ArgumentNullException(nameof(providerType));

            return _dictionary.TryGetValue(providerType, out values);
        }

        internal bool TryGetAction<TArgs>([NotNull] Type providerType, out TAction action)
            where TArgs : EventArgs
        {
            if (providerType == null) throw new ArgumentNullException(nameof(providerType));

            if (TryGetProvider(providerType, out var value))
            {
                action = value[typeof(TArgs)];
                return true;
            }
            action = default(TAction);
            return false;
        }

        internal TAction GetAction<TArgs>([NotNull] Type providerType)
            where TArgs : EventArgs
        {
            if (providerType == null) throw new ArgumentNullException(nameof(providerType));

            return _dictionary[providerType][typeof(TArgs)];
        }

        internal Dictionary<Type, TAction> GetProvider([NotNull] Type providerType)
        {
            if (providerType == null) throw new ArgumentNullException(nameof(providerType));

            return _dictionary[providerType];
        }

        #region Implementation of IEnumerable

        public IEnumerator<KeyValuePair<Type, Dictionary<Type, TAction>>> GetEnumerator()
        {
            return _dictionary.GetEnumerator();
        }

        IEnumerator IEnumerable.GetEnumerator()
        {
            return GetEnumerator();
        }

        #endregion
    }
}

Also the ObservableMap<,> and MediaObservableMap<,>

/// <summary>
/// Helper to avoid long dictionary declarations when used for <see cref="ICustomObservable{TArgs}"/>> mapping.
/// </summary>
internal class ObservableMap<T, TArgs> : Dictionary<T, EventHandler<TArgs>>
{
    protected Func<ObservableMap<T, TArgs>, T, bool> _predicate;
    protected Func<ObservableMap<T, TArgs>, TArgs, T> _selector;

    /// <param name="values">Values to populate into the <see cref="ObservableMap{T,TArgs}"/>.</param>
    /// <param name="selector">Used for complex index selection.</param>
    /// <param name="predicate">If the condition is false <see cref="TryGetHandler(T, out EventHandler{TArgs})"/> 
    /// will not look for entry and instead will return false.</param>
    internal ObservableMap(
        [NotNull] IDictionary<T, EventHandler<TArgs>> values,
        [NotNull] Func<ObservableMap<T, TArgs>, TArgs, T> selector,
        [NotNull]  Func<ObservableMap<T, TArgs>, T, bool> predicate)
        : this(values, selector)
    {
        _predicate = predicate ?? throw new ArgumentNullException(nameof(predicate));
    }

    /// <param name="values">Values to populate into the <see cref="ObservableMap{T,TArgs}"/>.</param>
    /// <param name="selector">Used for complex index selection.</param>
    internal ObservableMap(
        [NotNull]   IDictionary<T, EventHandler<TArgs>> values,
        [NotNull]  Func<ObservableMap<T, TArgs>, TArgs, T> selector)
        : base(values)
    {
        _selector = selector ?? throw new ArgumentNullException(nameof(selector));
    }

    /// <param name="selector">Used for complex index selection.</param>
    /// <param name="predicate">If the condition is false <see cref="TryGetHandler(T, out EventHandler{TArgs})"/> 
    /// will not look for entry and instead will return false.</param>
    internal ObservableMap(
        [NotNull]   Func<ObservableMap<T, TArgs>, TArgs, T> selector,
        [NotNull]   Func<ObservableMap<T, TArgs>, T, bool> predicate)
        : this(selector)
    {
        _predicate = predicate ?? throw new ArgumentNullException(nameof(predicate));
    }

    /// <param name="selector">Used for complex index selection.</param>
    internal ObservableMap(
        [NotNull]  Func<ObservableMap<T, TArgs>, TArgs, T> selector)
    {
        _selector = selector ?? throw new ArgumentNullException(nameof(selector));
    }

    internal virtual bool TryGetHandler([NotNull] TArgs args, out EventHandler<TArgs> handler)
    {
        if(args == null) throw new ArgumentNullException(nameof(args));
        return TryGetHandler(_selector.Invoke(this, args), out handler);
    }

    internal virtual bool TryGetHandler([NotNull] T key, out EventHandler<TArgs> handler)
    {
        if (key == null) throw new ArgumentNullException(nameof(key));

        if (_predicate?.Invoke(this, key) == false)
        {
            handler = null;
            return false;
        }
        return TryGetValue(key, out handler);
    }

    internal new EventHandler<TArgs> this[T key] => base[key];
}

internal class MediaObservableMap<TEnumeration> : ObservableMap<TEnumeration, MediaEventArgs<TEnumeration>>
    where TEnumeration : Enumeration<TEnumeration>
{
    internal MediaObservableMap(
        IDictionary<TEnumeration, EventHandler<MediaEventArgs<TEnumeration>>> values,
        Func<ObservableMap<TEnumeration, MediaEventArgs<TEnumeration>>, MediaEventArgs<TEnumeration>, TEnumeration> selector,
        Func<ObservableMap<TEnumeration, MediaEventArgs<TEnumeration>>, TEnumeration, bool> predicate)
        : base(values, selector, predicate)
    {
    }

    internal MediaObservableMap(
        IDictionary<TEnumeration, EventHandler<MediaEventArgs<TEnumeration>>> values,
        Func<ObservableMap<TEnumeration, MediaEventArgs<TEnumeration>>, MediaEventArgs<TEnumeration>, TEnumeration> selector)
        : base(values, selector)
    {
    }

    internal MediaObservableMap(
        Func<ObservableMap<TEnumeration, MediaEventArgs<TEnumeration>>, MediaEventArgs<TEnumeration>, TEnumeration> selector, 
        Func<ObservableMap<TEnumeration, MediaEventArgs<TEnumeration>>, TEnumeration, bool> predicate)
        : base(selector, predicate)
    {
    }

    internal MediaObservableMap(
        Func<ObservableMap<TEnumeration, MediaEventArgs<TEnumeration>>, MediaEventArgs<TEnumeration>, TEnumeration> selector) 
        : base(selector)
    {
    }
}

I have not included some specific classes and interfaces, because their implementations are not important for this question, but I will give a brief description of what they are supposed to do:

The types: SubscriptionEventType, MediaElementEventType and MediaControlEventType are all custom Enumeration classes, for their implementation please refer to this answer

The type ICustomObservable<> is very simple it has only 1 event Notify, which is used to notify subscribers.

The type SingleComparer<> is only used for the Binary Search used in the Resync method, it allows for a complex search to be performed, in this case it returns the first item matching the predicate.

All of the XEventArgs and IXViewModel types are not really important, the former is used to transfer information when the respective event is fired and the latter, to allow flexibility so you can use the SubtitleHandler with absolutely any sort of Controls, MediaElement, etc.

\$\endgroup\$
  • \$\begingroup\$ Doesn't that declaration: Func<ObservableMap<TEnumeration, MediaEventArgs<TEnumeration>>, MediaEventArgs<TEnumeration>, TEnumeration> bother you? :) \$\endgroup\$ – Nikita B Apr 26 '18 at 7:31
  • \$\begingroup\$ It does indeed.. @NikitaB \$\endgroup\$ – Denis Apr 26 '18 at 7:43

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.