From 09bd9bc1f9853ef0ea70217e1e5f8e92bfe65bea Mon Sep 17 00:00:00 2001 From: ema Date: Sun, 15 Dec 2024 03:59:32 +0800 Subject: [PATCH] Audio player support lyric (.lrc) #1506 Use lyric parser from https://github.com/lemutec/LyricStudio --- .../Extensions/StringExtension.cs | 6 +- .../LyricTrack/LrcHelper.cs | 171 ++++++++++++++++++ .../LyricTrack/LrcLine.cs | 161 +++++++++++++++++ .../QuickLook.Plugin.VideoViewer.csproj | 1 + .../ViewerPanel.xaml | 8 + .../ViewerPanel.xaml.cs | 43 +++++ 6 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 QuickLook.Plugin/QuickLook.Plugin.VideoViewer/LyricTrack/LrcHelper.cs create mode 100644 QuickLook.Plugin/QuickLook.Plugin.VideoViewer/LyricTrack/LrcLine.cs diff --git a/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/Extensions/StringExtension.cs b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/Extensions/StringExtension.cs index d79b33e..7532290 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/Extensions/StringExtension.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/Extensions/StringExtension.cs @@ -18,11 +18,7 @@ using System; using System.Collections.Generic; -#pragma warning disable IDE0130 // Namespace does not match folder structure - -namespace QuickLook.Plugin.VideoViewer; - -#pragma warning restore IDE0130 // Namespace does not match folder structure +namespace QuickLook.Plugin.VideoViewer.Extensions; internal static class StringExtension { diff --git a/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/LyricTrack/LrcHelper.cs b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/LyricTrack/LrcHelper.cs new file mode 100644 index 0000000..b42aa26 --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/LyricTrack/LrcHelper.cs @@ -0,0 +1,171 @@ +// Copyright © 2024 ema +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; + +namespace QuickLook.Plugin.VideoViewer.LyricTrack; + +/// +/// https://github.com/lemutec/LyricStudio +/// +public static class LrcHelper +{ + public static IEnumerable ParseText(string text) + { + List lrcList = []; + + if (string.IsNullOrWhiteSpace(text)) + { + return []; + } + + string[] lines = new Regex(@"\r?\n").Split(text); + + // The text does not contain timecode + if (!new Regex(@"\[\d+\:\d+\.\d+\]").IsMatch(text)) + { + foreach (string line in lines) + { + if (new Regex(@"\[\w+\:.*\]").IsMatch(line)) + { + lrcList.Add(new LrcLine(null, line.Trim('[', ']'))); + } + else + { + lrcList.Add(new LrcLine(0, line)); + } + } + } + // The text contain timecode + else + { + bool multiLrc = false; + + int lineNumber = 1; + try + { + foreach (string line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + { + lineNumber++; + continue; + } + + MatchCollection matches = new Regex(@"\[\d+\:\d+\.\d+\]").Matches(line); + + // Such as [00:00.000][00:01.000] + if (matches.Count > 1) + { + string lrc = new Regex(@"(?<=\])[^\]]+$").Match(line).ToString(); + + lrcList.AddRange(matches.OfType().Select(match => new LrcLine(ParseTimeSpan(match.ToString().Trim('[', ']')), lrc))); + multiLrc = true; + } + // Normal line like [00:00.000] + else if (matches.Count == 1) + { + lrcList.Add(LrcLine.Parse(line)); + } + // Info line + else if (new Regex(@"\[\w+\:.*\]").IsMatch(line)) + { + lrcList.Add(new LrcLine(null, new Regex(@"\[\w+\:.*\]").Match(line).ToString().Trim('[', ']'))); + } + // Not an empty line but no any timecode was found, so add an empty timecode + else + { + lrcList.Add(new LrcLine(TimeSpan.Zero, line)); + } + lineNumber++; + } + // Multi timecode and sort it auto + if (multiLrc) + { + lrcList = [.. lrcList.OrderBy(x => x.LrcTime)]; + } + } + catch (Exception e) + { + // Some error occurred in {{ lineNumber }} + Debug.WriteLine(e); + } + } + + return lrcList; + } + + public static LrcLine GetNearestLrc(IEnumerable lrcList, TimeSpan time) + { + LrcLine line = lrcList + .Where(x => x.LrcTime != null && x.LrcTime <= time) + .OrderByDescending(x => x.LrcTime) + .FirstOrDefault(); + + return line; + } + + /// + /// Try to resolve the timestamp string to TimeSpan, see + /// + /// + public static bool TryParseTimeSpan(string s, out TimeSpan ts) + { + try + { + ts = ParseTimeSpan(s); + return true; + } + catch + { + ts = TimeSpan.Zero; + return false; + } + } + + /// + /// Resolves the timestamp string to a TimeSpan + /// + public static TimeSpan ParseTimeSpan(string s) + { + // If the millisecond is two-digit, add an extra 0 at the end + if (s.Split('.')[1].Length == 2) + { + s += '0'; + } + return TimeSpan.Parse("00:" + s); + } + + /// + /// Change the timestamp to a two-digit millisecond format + /// + public static string ToShortString(this TimeSpan ts, bool isShort = false) + { + if (isShort) + { + return $"{ts.Minutes:00}:{ts.Seconds:00}.{ts.Milliseconds / 10:00}"; + } + else + { + return $"{ts.Minutes:00}:{ts.Seconds:00}.{ts.Milliseconds:000}"; + } + } +} diff --git a/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/LyricTrack/LrcLine.cs b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/LyricTrack/LrcLine.cs new file mode 100644 index 0000000..22ca83d --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/LyricTrack/LrcLine.cs @@ -0,0 +1,161 @@ +// Copyright © 2024 ema +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System; +using System.Diagnostics; + +namespace QuickLook.Plugin.VideoViewer.LyricTrack; + +/// +/// https://github.com/lemutec/LyricStudio +/// https://en.wikipedia.org/wiki/LRC_(file_format) +/// +[DebuggerDisplay("{PreviewText}")] +public class LrcLine : IComparable +{ + public static readonly LrcLine Empty = new(); + + public TimeSpan? LrcTime { get; set; } = default; + + public static bool IsShort { get; set; } = false; + + public string LrcTimeText + { + get => LrcTime.HasValue ? LrcHelper.ToShortString(LrcTime.Value, IsShort) : string.Empty; + set + { + if (LrcHelper.TryParseTimeSpan(value, out TimeSpan ts)) + { + LrcTime = ts; + } + else + { + LrcTime = null; + } + } + } + + public string LrcText { get; set; } + + /// + /// Preview such as [{LrcTime:mm:ss.fff}]{LyricText} + /// + public string PreviewText + { + get + { + if (LrcTime.HasValue) + { + return $"[{LrcHelper.ToShortString(LrcTime.Value, IsShort)}]{LrcText}"; + } + else if (!string.IsNullOrWhiteSpace(LrcText)) + { + return $"[{LrcText}]"; + } + else + { + return string.Empty; + } + } + } + + public LrcLine(double time, string text) + { + LrcTime = new TimeSpan(0, 0, 0, 0, (int)(time * 1000)); + LrcText = text; + } + + public LrcLine(TimeSpan? time, string text) + { + LrcTime = time; + LrcText = text; + } + + public LrcLine(TimeSpan? time) + : this(time, string.Empty) + { + } + + public LrcLine(LrcLine lrcLine) + { + LrcTime = lrcLine.LrcTime; + LrcText = lrcLine.LrcText; + } + + public LrcLine(string line) + : this(Parse(line)) + { + } + + public LrcLine() + { + LrcTime = null; + LrcText = string.Empty; + } + + public static LrcLine Parse(string line) + { + if (string.IsNullOrWhiteSpace(line)) + { + return Empty; + } + + if (CheckMultiLine(line)) + { + throw new FormatException(); + } + + string[] slices = line.TrimStart().TrimStart('[').Split(']'); + + if (!LrcHelper.TryParseTimeSpan(slices[0], out TimeSpan time)) + { + return new LrcLine(null, slices[0]); + } + + return new LrcLine(time, slices[1]); + } + + public static bool TryParse(string line, out LrcLine lrcLine) + { + try + { + lrcLine = Parse(line); + return true; + } + catch + { + lrcLine = Empty; + return false; + } + } + + public static bool CheckMultiLine(string line) + { + if (line.TrimStart().IndexOf('[', 1) != -1) return true; + else return false; + } + + public override string ToString() => PreviewText; + + public int CompareTo(LrcLine other) + { + // Sort order: null < TimeSpan < string + if (!LrcTime.HasValue) return -1; + if (!other.LrcTime.HasValue) return 1; + return LrcTime.Value.CompareTo(other.LrcTime.Value); + } +} diff --git a/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/QuickLook.Plugin.VideoViewer.csproj b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/QuickLook.Plugin.VideoViewer.csproj index 74b6853..05b70c8 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/QuickLook.Plugin.VideoViewer.csproj +++ b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/QuickLook.Plugin.VideoViewer.csproj @@ -44,6 +44,7 @@ False Mediakit\Assemblies\DirectShowLib-2005.dll + diff --git a/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/ViewerPanel.xaml b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/ViewerPanel.xaml index c8bfcf7..3f076b6 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/ViewerPanel.xaml +++ b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/ViewerPanel.xaml @@ -116,6 +116,14 @@ Foreground="{DynamicResource WindowTextForeground}" Text="{Binding ElementName=mediaElement, Path=MediaDuration, Converter={StaticResource TimeTickToShortStringConverter}}" TextTrimming="CharacterEllipsis" /> + diff --git a/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/ViewerPanel.xaml.cs b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/ViewerPanel.xaml.cs index 530c9ae..52bcdcf 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/ViewerPanel.xaml.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.VideoViewer/ViewerPanel.xaml.cs @@ -19,11 +19,15 @@ using MediaInfo; using QuickLook.Common.Annotations; using QuickLook.Common.Helpers; using QuickLook.Common.Plugin; +using QuickLook.Plugin.VideoViewer.Extensions; +using QuickLook.Plugin.VideoViewer.LyricTrack; using System; using System.ComponentModel; using System.Diagnostics; using System.IO; +using System.Linq; using System.Runtime.CompilerServices; +using System.Text; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; @@ -31,6 +35,8 @@ using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Media.Imaging; +using System.Windows.Threading; +using UtfUnknown; using WPFMediaKit.DirectShow.Controls; using WPFMediaKit.DirectShow.MediaPlayers; @@ -43,6 +49,8 @@ public partial class ViewerPanel : UserControl, IDisposable, INotifyPropertyChan { private readonly ContextObject _context; private BitmapSource _coverArt; + private DispatcherTimer _lyricTimer; + private LrcLine[] _lyricLines; private bool _hasVideo; private bool _isPlaying; @@ -161,6 +169,10 @@ public partial class ViewerPanel : UserControl, IDisposable, INotifyPropertyChan { Debug.WriteLine(e); } + + _lyricTimer?.Stop(); + _lyricTimer = null; + _lyricLines = null; } public event PropertyChangedEventHandler PropertyChanged; @@ -285,6 +297,37 @@ public partial class ViewerPanel : UserControl, IDisposable, INotifyPropertyChan metaAlbum.Visibility = string.IsNullOrEmpty(metaAlbum.Text) ? Visibility.Collapsed : Visibility.Visible; + + var lyricPath = Path.ChangeExtension(path, ".lrc"); + + if (File.Exists(lyricPath)) + { + var buffer = File.ReadAllBytes(lyricPath); + var encoding = CharsetDetector.DetectFromBytes(buffer).Detected?.Encoding ?? Encoding.Default; + + _lyricLines = LrcHelper.ParseText(encoding.GetString(buffer)).ToArray(); + _lyricTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(100) }; + _lyricTimer.Tick += (sender, e) => + { + if (_lyricLines != null && _lyricLines.Length != 0) + { + var lyric = LrcHelper.GetNearestLrc(_lyricLines, new TimeSpan(mediaElement.MediaPosition)); + metaLyric.Text = lyric?.LrcText?.Trim(); + } + else + { + metaLyric.Text = null; + metaLyric.Visibility = Visibility.Collapsed; + } + }; + _lyricTimer.Start(); + + metaLyric.Visibility = Visibility.Visible; + } + else + { + metaLyric.Visibility = Visibility.Collapsed; + } } // Newer .net has Math.Clamp