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