From d7e35ecba6825cf52f5cc680463445194053b4fd Mon Sep 17 00:00:00 2001
From: Emma <153868115+GayPotatoEmma@users.noreply.github.com>
Date: Thu, 26 Mar 2026 13:27:20 +0100
Subject: [PATCH 1/4] add support for lyrics
---
FantasyPlayer.Mock/FantasyPlayer.Mock.csproj | 6 +-
FantasyPlayer.Mock/MockPlugin.cs | 2 +-
.../FantasyPlayer.Spotify.csproj | 7 +
FantasyPlayer.sln | 24 +++
FantasyPlayer/Config/Configuration.cs | 4 +-
FantasyPlayer/Config/LyricsSettings.cs | 77 +++++++
.../Interface/Window/SettingsWindow.cs | 53 +++++
FantasyPlayer/Lyrics/LrcLibResponse.cs | 25 +++
FantasyPlayer/Lyrics/LyricsManager.cs | 195 ++++++++++++++++++
FantasyPlayer/Lyrics/LyricsService.cs | 88 ++++++++
FantasyPlayer/Plugin.cs | 10 +-
FantasyPlayer/Provider/SpotifyProvider.cs | 10 +
12 files changed, 495 insertions(+), 6 deletions(-)
create mode 100644 FantasyPlayer/Config/LyricsSettings.cs
create mode 100644 FantasyPlayer/Lyrics/LrcLibResponse.cs
create mode 100644 FantasyPlayer/Lyrics/LyricsManager.cs
create mode 100644 FantasyPlayer/Lyrics/LyricsService.cs
diff --git a/FantasyPlayer.Mock/FantasyPlayer.Mock.csproj b/FantasyPlayer.Mock/FantasyPlayer.Mock.csproj
index 8b86590..547aab1 100644
--- a/FantasyPlayer.Mock/FantasyPlayer.Mock.csproj
+++ b/FantasyPlayer.Mock/FantasyPlayer.Mock.csproj
@@ -85,7 +85,7 @@
Always
-
+
Always
@@ -94,7 +94,7 @@
Always
-
+
Always
@@ -103,7 +103,7 @@
Always
-
+
Always
diff --git a/FantasyPlayer.Mock/MockPlugin.cs b/FantasyPlayer.Mock/MockPlugin.cs
index e1a00aa..24f3bd5 100644
--- a/FantasyPlayer.Mock/MockPlugin.cs
+++ b/FantasyPlayer.Mock/MockPlugin.cs
@@ -9,7 +9,7 @@ namespace FantasyPlayer.Mock;
public class MockPlugin : Plugin
{
- public MockPlugin(IDalamudPluginInterface pluginInterface, IPluginLog pluginLog, IFramework framework, IClientState clientState, IChatGui chatGui, ICommandManager commandManager, ICondition condition) : base(pluginInterface, pluginLog, framework, clientState, chatGui, commandManager, condition)
+ public MockPlugin(IDalamudPluginInterface pluginInterface, IPluginLog pluginLog, IFramework framework, IClientState clientState, IChatGui chatGui, ICommandManager commandManager, ICondition condition, IFlyTextGui flyTextGui) : base(pluginInterface, pluginLog, framework, clientState, chatGui, commandManager, condition, flyTextGui)
{
}
diff --git a/FantasyPlayer.Spotify/FantasyPlayer.Spotify.csproj b/FantasyPlayer.Spotify/FantasyPlayer.Spotify.csproj
index 13e743b..fd305bd 100644
--- a/FantasyPlayer.Spotify/FantasyPlayer.Spotify.csproj
+++ b/FantasyPlayer.Spotify/FantasyPlayer.Spotify.csproj
@@ -8,13 +8,20 @@
Copyright © 2020
false
bin\$(Configuration)\
+ AnyCPU;x64
full
+
+ full
+
pdbonly
+
+ pdbonly
+
diff --git a/FantasyPlayer.sln b/FantasyPlayer.sln
index 556f573..6a0fd00 100644
--- a/FantasyPlayer.sln
+++ b/FantasyPlayer.sln
@@ -1,5 +1,8 @@
Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 18
+VisualStudioVersion = 18.5.11619.145 insiders
+MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FantasyPlayer.Spotify", "FantasyPlayer.Spotify\FantasyPlayer.Spotify.csproj", "{64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FantasyPlayer", "FantasyPlayer\FantasyPlayer.csproj", "{7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}"
@@ -11,24 +14,45 @@ EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Debug|x64.ActiveCfg = Debug|x64
+ {64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Debug|x64.Build.0 = Debug|x64
{64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Release|Any CPU.ActiveCfg = Release|Any CPU
{64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Release|Any CPU.Build.0 = Release|Any CPU
+ {64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Release|x64.ActiveCfg = Release|x64
+ {64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Release|x64.Build.0 = Release|x64
{7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Debug|x64.ActiveCfg = Debug|x64
+ {7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Debug|x64.Build.0 = Debug|x64
{7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Release|x64.ActiveCfg = Release|x64
+ {7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Release|x64.Build.0 = Release|x64
{DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Debug|x64.ActiveCfg = Debug|x64
+ {DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Debug|x64.Build.0 = Debug|x64
{DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Release|x64.ActiveCfg = Release|x64
+ {DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Release|x64.Build.0 = Release|x64
{668953BC-B270-43F7-952B-706ABFDD6DEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{668953BC-B270-43F7-952B-706ABFDD6DEC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {668953BC-B270-43F7-952B-706ABFDD6DEC}.Debug|x64.ActiveCfg = Debug|x64
+ {668953BC-B270-43F7-952B-706ABFDD6DEC}.Debug|x64.Build.0 = Debug|x64
{668953BC-B270-43F7-952B-706ABFDD6DEC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{668953BC-B270-43F7-952B-706ABFDD6DEC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {668953BC-B270-43F7-952B-706ABFDD6DEC}.Release|x64.ActiveCfg = Release|x64
+ {668953BC-B270-43F7-952B-706ABFDD6DEC}.Release|x64.Build.0 = Release|x64
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
diff --git a/FantasyPlayer/Config/Configuration.cs b/FantasyPlayer/Config/Configuration.cs
index f981780..7aebd84 100644
--- a/FantasyPlayer/Config/Configuration.cs
+++ b/FantasyPlayer/Config/Configuration.cs
@@ -14,7 +14,7 @@ public class Configuration : IPluginConfiguration, INotifyPropertyChanged
private bool isDirty;
public bool IsDirty
{
- get => isDirty || PlayerSettings.IsDirty || SpotifySettings.IsDirty || AutoPlaySettings.IsDirty;
+ get => isDirty || PlayerSettings.IsDirty || SpotifySettings.IsDirty || AutoPlaySettings.IsDirty || LyricsSettings.IsDirty;
set => SetField(ref isDirty, value, false);
}
@@ -24,6 +24,7 @@ public void MarkClean()
PlayerSettings.MarkClean();
SpotifySettings.MarkClean();
AutoPlaySettings.MarkClean();
+ LyricsSettings.MarkClean();
}
private bool displayChatMessages;
private bool configShown;
@@ -32,6 +33,7 @@ public void MarkClean()
public PlayerSettings PlayerSettings { get; set; } = new PlayerSettings();
public SpotifySettings SpotifySettings { get; set; } = new SpotifySettings();
public AutoPlaySettings AutoPlaySettings { get; set; } = new AutoPlaySettings();
+ public LyricsSettings LyricsSettings { get; set; } = new LyricsSettings();
public bool DisplayChatMessages
{
diff --git a/FantasyPlayer/Config/LyricsSettings.cs b/FantasyPlayer/Config/LyricsSettings.cs
new file mode 100644
index 0000000..6de2d52
--- /dev/null
+++ b/FantasyPlayer/Config/LyricsSettings.cs
@@ -0,0 +1,77 @@
+using Dalamud.Game.Text;
+
+namespace FantasyPlayer.Config
+{
+ using System.Collections.Generic;
+ using System.ComponentModel;
+ using System.Runtime.CompilerServices;
+
+ public enum LyricDisplayMode
+ {
+ Chat = 0,
+ FlyText = 1,
+ }
+
+ public class LyricsSettings : INotifyPropertyChanged
+ {
+ private bool isDirty;
+ public bool IsDirty
+ {
+ get => isDirty;
+ private set => SetField(ref isDirty, value, false);
+ }
+
+ public void MarkClean()
+ {
+ IsDirty = false;
+ }
+
+ private bool enableLyrics;
+ public bool EnableLyrics
+ {
+ get => enableLyrics;
+ set => SetField(ref enableLyrics, value);
+ }
+
+ private LyricDisplayMode displayMode = LyricDisplayMode.Chat;
+ public LyricDisplayMode DisplayMode
+ {
+ get => displayMode;
+ set => SetField(ref displayMode, value);
+ }
+
+ private XivChatType chatType = XivChatType.Echo;
+ public XivChatType ChatType
+ {
+ get => chatType;
+ set => SetField(ref chatType, value);
+ }
+
+ private uint flyTextColor = 0xFF60D71E;
+ public uint FlyTextColor
+ {
+ get => flyTextColor;
+ set => SetField(ref flyTextColor, value);
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+
+ protected bool SetField(ref T field, T value, bool markDirty = true, [CallerMemberName] string? propertyName = null)
+ {
+ if (EqualityComparer.Default.Equals(field, value)) return false;
+ field = value;
+ OnPropertyChanged(propertyName);
+ if (markDirty)
+ {
+ IsDirty = true;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/FantasyPlayer/Interface/Window/SettingsWindow.cs b/FantasyPlayer/Interface/Window/SettingsWindow.cs
index 0063082..56cdfec 100644
--- a/FantasyPlayer/Interface/Window/SettingsWindow.cs
+++ b/FantasyPlayer/Interface/Window/SettingsWindow.cs
@@ -1,4 +1,5 @@
using Dalamud.Bindings.ImGui;
+using System;
using System.Numerics;
using AllaganLib.Shared.Extensions;
using Dalamud.Interface;
@@ -290,7 +291,59 @@ private void MainWindow()
}
}
+ if (ImGui.CollapsingHeader("Lyrics Settings"))
+ {
+ var enableLyrics = _configuration.LyricsSettings.EnableLyrics;
+ if (ImGui.Checkbox("Enable Lyrics", ref enableLyrics))
+ {
+ _configuration.LyricsSettings.EnableLyrics = enableLyrics;
+ }
+
+ var displayModeIndex = (int)_configuration.LyricsSettings.DisplayMode;
+ string[] displayModes = { "Chat", "Flytext" };
+ if (ImGui.Combo("Lyric Display Mode", ref displayModeIndex, displayModes, displayModes.Length))
+ {
+ _configuration.LyricsSettings.DisplayMode = (Config.LyricDisplayMode)displayModeIndex;
+ }
+
+ if (_configuration.LyricsSettings.DisplayMode == Config.LyricDisplayMode.Chat)
+ {
+ if (Widget.DrawChatTypeSelector("Lyrics chat output channel",
+ "To which chat channel should the lyrics be printed?",
+ _configuration.LyricsSettings.ChatType,
+ type => { _configuration.LyricsSettings.ChatType = type; }))
+ {
+ }
+ }
+ else
+ {
+ var lyricColor = AbgrToVector4(_configuration.LyricsSettings.FlyTextColor);
+ if (ImGui.ColorEdit4("Flytext Color", ref lyricColor))
+ {
+ _configuration.LyricsSettings.FlyTextColor = Vector4ToAbgr(lyricColor);
+ }
+ }
+ }
+
ImGui.Separator();
}
+
+ private static Vector4 AbgrToVector4(uint abgr)
+ {
+ var r = (abgr & 0xFF) / 255f;
+ var g = ((abgr >> 8) & 0xFF) / 255f;
+ var b = ((abgr >> 16) & 0xFF) / 255f;
+ var a = ((abgr >> 24) & 0xFF) / 255f;
+ return new Vector4(r, g, b, a);
+ }
+
+ private static uint Vector4ToAbgr(Vector4 color)
+ {
+ var r = (uint)Math.Clamp(color.X * 255f, 0f, 255f);
+ var g = (uint)Math.Clamp(color.Y * 255f, 0f, 255f);
+ var b = (uint)Math.Clamp(color.Z * 255f, 0f, 255f);
+ var a = (uint)Math.Clamp(color.W * 255f, 0f, 255f);
+ return (a << 24) | (b << 16) | (g << 8) | r;
+ }
}
}
\ No newline at end of file
diff --git a/FantasyPlayer/Lyrics/LrcLibResponse.cs b/FantasyPlayer/Lyrics/LrcLibResponse.cs
new file mode 100644
index 0000000..772c92b
--- /dev/null
+++ b/FantasyPlayer/Lyrics/LrcLibResponse.cs
@@ -0,0 +1,25 @@
+using System.Text.Json.Serialization;
+
+namespace FantasyPlayer.Lyrics
+{
+ public class LrcLibResponse
+ {
+ [JsonPropertyName("id")]
+ public int Id { get; set; }
+
+ [JsonPropertyName("trackName")]
+ public string? TrackName { get; set; }
+
+ [JsonPropertyName("artistName")]
+ public string? ArtistName { get; set; }
+
+ [JsonPropertyName("albumName")]
+ public string? AlbumName { get; set; }
+
+ [JsonPropertyName("duration")]
+ public double Duration { get; set; }
+
+ [JsonPropertyName("syncedLyrics")]
+ public string? SyncedLyrics { get; set; }
+ }
+}
diff --git a/FantasyPlayer/Lyrics/LyricsManager.cs b/FantasyPlayer/Lyrics/LyricsManager.cs
new file mode 100644
index 0000000..af797c8
--- /dev/null
+++ b/FantasyPlayer/Lyrics/LyricsManager.cs
@@ -0,0 +1,195 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Dalamud.Game.Gui.FlyText;
+using Dalamud.Game.Text;
+using Dalamud.Game.Text.SeStringHandling;
+using Dalamud.Game.Text.SeStringHandling.Payloads;
+using Dalamud.Plugin.Services;
+using FantasyPlayer.Config;
+using FantasyPlayer.Manager;
+using Microsoft.Extensions.Hosting;
+
+namespace FantasyPlayer.Lyrics
+{
+ public class LyricsManager : BackgroundService
+ {
+ private readonly IPluginLog _pluginLog;
+ private readonly Configuration _configuration;
+ private readonly PlayerManager _playerManager;
+ private readonly LyricsService _lyricsService;
+ private readonly IChatGui _chatGui;
+ private readonly IFlyTextGui _flyTextGui;
+ private readonly IFramework _framework;
+
+ private string? _lastTrackId;
+ private List<(TimeSpan Time, string Text)> _currentLyrics = new();
+ private int _lastLyricIndex = -1;
+ private bool _isFetching;
+
+ private int _lastProgressMs = -1;
+ private DateTime _lastProgressTime = DateTime.MinValue;
+
+ public LyricsManager(
+ IPluginLog pluginLog,
+ Configuration configuration,
+ PlayerManager playerManager,
+ LyricsService lyricsService,
+ IChatGui chatGui,
+ IFlyTextGui flyTextGui,
+ IFramework framework)
+ {
+ _pluginLog = pluginLog;
+ _configuration = configuration;
+ _playerManager = playerManager;
+ _lyricsService = lyricsService;
+ _chatGui = chatGui;
+ _flyTextGui = flyTextGui;
+ _framework = framework;
+ }
+
+ public override async Task StartAsync(CancellationToken cancellationToken)
+ {
+ _framework.Update += OnFrameworkUpdate;
+ await base.StartAsync(cancellationToken);
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ _framework.Update -= OnFrameworkUpdate;
+ await base.StopAsync(cancellationToken);
+ }
+
+ protected override Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ private void OnFrameworkUpdate(IFramework framework)
+ {
+ if (!_configuration.LyricsSettings.EnableLyrics)
+ return;
+
+ var provider = _playerManager.CurrentPlayerProvider;
+ if (provider == null)
+ return;
+
+ var state = provider.PlayerState;
+ if (!state.IsPlaying)
+ return;
+
+ var currentTrack = state.CurrentlyPlaying;
+ var trackId = currentTrack.Id;
+
+ if (trackId != _lastTrackId)
+ {
+ _lastTrackId = trackId;
+ _lastLyricIndex = -1;
+ _lastProgressMs = -1;
+ _lastProgressTime = DateTime.MinValue;
+ _currentLyrics = new List<(TimeSpan, string)>();
+
+ if (!string.IsNullOrEmpty(trackId) && !_isFetching)
+ {
+ var artist = currentTrack.Artists.Length > 0 ? currentTrack.Artists[0] : string.Empty;
+ _ = FetchLyricsAsync(artist, currentTrack.Name, currentTrack.Album.Name, currentTrack.DurationMs);
+ }
+ }
+
+ if (_currentLyrics.Count == 0)
+ return;
+
+ var apiProgressMs = state.ProgressMs;
+ var now = DateTime.UtcNow;
+
+ TimeSpan currentTime;
+ if (_lastProgressMs >= 0 && apiProgressMs == _lastProgressMs)
+ {
+ currentTime = TimeSpan.FromMilliseconds(_lastProgressMs) + (now - _lastProgressTime);
+ }
+ else
+ {
+ if (_lastProgressMs >= 0)
+ {
+ var expectedMs = _lastProgressMs + (int)(now - _lastProgressTime).TotalMilliseconds;
+ if (apiProgressMs < _lastProgressMs || Math.Abs(apiProgressMs - expectedMs) > 5000)
+ {
+ _lastLyricIndex = -1;
+ }
+ }
+
+ _lastProgressMs = apiProgressMs;
+ _lastProgressTime = now;
+ currentTime = TimeSpan.FromMilliseconds(apiProgressMs);
+ }
+
+ for (var i = _lastLyricIndex + 1; i < _currentLyrics.Count; i++)
+ {
+ var lyric = _currentLyrics[i];
+ if (currentTime < lyric.Time)
+ break;
+
+ var isLastLine = i + 1 >= _currentLyrics.Count;
+ var beforeNext = isLastLine || currentTime < _currentLyrics[i + 1].Time;
+
+ if (beforeNext)
+ {
+ DisplayLyric(lyric.Text);
+ _lastLyricIndex = i;
+ break;
+ }
+ }
+ }
+
+ private async Task FetchLyricsAsync(string artist, string title, string album, int durationMs)
+ {
+ _isFetching = true;
+ try
+ {
+ var lyrics = await _lyricsService.FetchSyncedLyricsAsync(artist, title, album, durationMs);
+ _currentLyrics = lyrics;
+ _lastLyricIndex = -1;
+ }
+ catch (Exception ex)
+ {
+ _pluginLog.Error($"Failed to fetch lyrics: {ex.Message}");
+ }
+ finally
+ {
+ _isFetching = false;
+ }
+ }
+
+ private void DisplayLyric(string text)
+ {
+ try
+ {
+ var line = $"♪ {text}";
+ if (_configuration.LyricsSettings.DisplayMode == LyricDisplayMode.FlyText)
+ {
+ _flyTextGui.AddFlyText(
+ FlyTextKind.Named,
+ 1, 0, 0,
+ new SeString(new List { new TextPayload(line) }),
+ SeString.Empty,
+ _configuration.LyricsSettings.FlyTextColor,
+ 0, 0);
+ }
+ else
+ {
+ _chatGui.Print(new XivChatEntry
+ {
+ Message = new SeString(new List { new TextPayload(line) }),
+ Name = SeString.Empty,
+ Type = _configuration.LyricsSettings.ChatType,
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ _pluginLog.Error($"Failed to display lyric: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/FantasyPlayer/Lyrics/LyricsService.cs b/FantasyPlayer/Lyrics/LyricsService.cs
new file mode 100644
index 0000000..0222540
--- /dev/null
+++ b/FantasyPlayer/Lyrics/LyricsService.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Dalamud.Plugin.Services;
+
+namespace FantasyPlayer.Lyrics
+{
+ public class LyricsService : IDisposable
+ {
+ private static readonly HttpClient HttpClient = new HttpClient();
+ private static readonly Regex LrcLineRegex = new Regex(@"\[(\d+):(\d+)\.(\d+)\](.*)", RegexOptions.Compiled);
+
+ private readonly IPluginLog _pluginLog;
+
+ public LyricsService(IPluginLog pluginLog)
+ {
+ _pluginLog = pluginLog;
+ HttpClient.DefaultRequestHeaders.UserAgent.TryParseAdd("FantasyPlayer/1.0");
+ }
+
+ public async Task> FetchSyncedLyricsAsync(
+ string artist, string title, string album, int durationMs)
+ {
+ try
+ {
+ var durationSeconds = durationMs / 1000;
+ var url = $"https://lrclib.net/api/get" +
+ $"?artist_name={Uri.EscapeDataString(artist)}" +
+ $"&track_name={Uri.EscapeDataString(title)}" +
+ $"&album_name={Uri.EscapeDataString(album)}" +
+ $"&duration={durationSeconds}";
+
+ var response = await HttpClient.GetAsync(url);
+ if (!response.IsSuccessStatusCode)
+ {
+ _pluginLog.Debug($"LRCLIB returned {(int)response.StatusCode} for {artist} - {title}");
+ return new List<(TimeSpan, string)>();
+ }
+
+ var content = await response.Content.ReadAsStringAsync();
+ var lrcResponse = JsonSerializer.Deserialize(content);
+ if (lrcResponse?.SyncedLyrics == null)
+ {
+ _pluginLog.Debug($"No synced lyrics found for {artist} - {title}");
+ return new List<(TimeSpan, string)>();
+ }
+
+ var lines = ParseLrc(lrcResponse.SyncedLyrics);
+ _pluginLog.Debug($"Parsed {lines.Count} lyric lines for {artist} - {title}");
+ return lines;
+ }
+ catch (Exception ex)
+ {
+ _pluginLog.Error($"Failed to fetch lyrics from LRCLIB: {ex.Message}");
+ return new List<(TimeSpan, string)>();
+ }
+ }
+
+ private static List<(TimeSpan Time, string Text)> ParseLrc(string lrc)
+ {
+ var lines = new List<(TimeSpan, string)>();
+ foreach (var rawLine in lrc.Split('\n'))
+ {
+ var match = LrcLineRegex.Match(rawLine.Trim());
+ if (!match.Success) continue;
+
+ var minutes = int.Parse(match.Groups[1].Value);
+ var seconds = int.Parse(match.Groups[2].Value);
+ var centiseconds = int.Parse(match.Groups[3].Value);
+ var text = match.Groups[4].Value.Trim();
+
+ if (string.IsNullOrWhiteSpace(text)) continue;
+
+ var time = new TimeSpan(0, 0, minutes, seconds, centiseconds * 10);
+ lines.Add((time, text));
+ }
+
+ return lines;
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/FantasyPlayer/Plugin.cs b/FantasyPlayer/Plugin.cs
index 2ed9df8..698a489 100644
--- a/FantasyPlayer/Plugin.cs
+++ b/FantasyPlayer/Plugin.cs
@@ -3,6 +3,7 @@
using FantasyPlayer.Config;
using FantasyPlayer.Interface;
using FantasyPlayer.Interfaces;
+using FantasyPlayer.Lyrics;
using FantasyPlayer.Manager;
namespace FantasyPlayer
@@ -21,8 +22,11 @@ namespace FantasyPlayer
public class Plugin : HostedPlugin
{
- public Plugin(IDalamudPluginInterface pluginInterface, IPluginLog pluginLog, IFramework framework, IClientState clientState, IChatGui chatGui, ICommandManager commandManager, ICondition condition) : base(pluginInterface, pluginLog, framework, clientState, chatGui, commandManager, condition)
+ private readonly IFlyTextGui _flyTextGui;
+
+ public Plugin(IDalamudPluginInterface pluginInterface, IPluginLog pluginLog, IFramework framework, IClientState clientState, IChatGui chatGui, ICommandManager commandManager, ICondition condition, IFlyTextGui flyTextGui) : base(pluginInterface, pluginLog, framework, clientState, chatGui, commandManager, condition)
{
+ _flyTextGui = flyTextGui;
CreateHost();
Start();
}
@@ -37,6 +41,7 @@ public override HostedPluginOptions ConfigureOptions()
public override void ConfigureContainer(ContainerBuilder containerBuilder)
{
+ containerBuilder.RegisterInstance(_flyTextGui).As().SingleInstance();
containerBuilder.RegisterType().SingleInstance();
containerBuilder.Register(provider =>
{
@@ -54,6 +59,8 @@ public override void ConfigureContainer(ContainerBuilder containerBuilder)
containerBuilder.RegisterType().As().SingleInstance();
containerBuilder.RegisterType().As().SingleInstance();
containerBuilder.RegisterType().SingleInstance();
+ containerBuilder.RegisterType().SingleInstance();
+ containerBuilder.RegisterType().SingleInstance();
containerBuilder.RegisterType().As().SingleInstance();
containerBuilder.RegisterType().As().SingleInstance();
}
@@ -65,6 +72,7 @@ public override void ConfigureServices(IServiceCollection serviceCollection)
serviceCollection.AddHostedService(p => p.GetRequiredService());
serviceCollection.AddHostedService(p => p.GetRequiredService());
serviceCollection.AddHostedService(p => p.GetRequiredService());
+ serviceCollection.AddHostedService(p => p.GetRequiredService());
serviceCollection.AddHostedService(p => p.GetRequiredService());
}
}
diff --git a/FantasyPlayer/Provider/SpotifyProvider.cs b/FantasyPlayer/Provider/SpotifyProvider.cs
index c63068e..0e2a422 100644
--- a/FantasyPlayer/Provider/SpotifyProvider.cs
+++ b/FantasyPlayer/Provider/SpotifyProvider.cs
@@ -132,6 +132,16 @@ private void OnLoggedIn(PrivateUser privateUser, PKCETokenResponse tokenResponse
public void Update()
{
+ if (_spotifyState?.CurrentlyPlaying != null)
+ {
+ var progressMs = _spotifyState.CurrentlyPlaying.ProgressMs;
+ if (PlayerState.ProgressMs != progressMs)
+ {
+ var playerStateStruct = PlayerState;
+ playerStateStruct.ProgressMs = progressMs;
+ PlayerState = playerStateStruct;
+ }
+ }
}
public void ReAuth()
From 33ba357cfccde154ac881bfd587d7df79901b571 Mon Sep 17 00:00:00 2001
From: Emma <153868115+GayPotatoEmma@users.noreply.github.com>
Date: Thu, 26 Mar 2026 13:27:20 +0100
Subject: [PATCH 2/4] add support for lyrics
---
FantasyPlayer.Mock/FantasyPlayer.Mock.csproj | 6 +-
FantasyPlayer.Mock/MockPlugin.cs | 2 +-
.../FantasyPlayer.Spotify.csproj | 7 +
FantasyPlayer.sln | 24 +++
FantasyPlayer/Config/Configuration.cs | 4 +-
FantasyPlayer/Config/LyricsSettings.cs | 77 +++++++
.../Interface/Window/SettingsWindow.cs | 53 +++++
FantasyPlayer/Lyrics/LrcLibResponse.cs | 25 +++
FantasyPlayer/Lyrics/LyricsManager.cs | 195 ++++++++++++++++++
FantasyPlayer/Lyrics/LyricsService.cs | 88 ++++++++
FantasyPlayer/Plugin.cs | 6 +-
FantasyPlayer/Provider/SpotifyProvider.cs | 10 +
OtterGui | 2 +-
13 files changed, 492 insertions(+), 7 deletions(-)
create mode 100644 FantasyPlayer/Config/LyricsSettings.cs
create mode 100644 FantasyPlayer/Lyrics/LrcLibResponse.cs
create mode 100644 FantasyPlayer/Lyrics/LyricsManager.cs
create mode 100644 FantasyPlayer/Lyrics/LyricsService.cs
diff --git a/FantasyPlayer.Mock/FantasyPlayer.Mock.csproj b/FantasyPlayer.Mock/FantasyPlayer.Mock.csproj
index 895c52c..9897f7c 100644
--- a/FantasyPlayer.Mock/FantasyPlayer.Mock.csproj
+++ b/FantasyPlayer.Mock/FantasyPlayer.Mock.csproj
@@ -85,7 +85,7 @@
Always
-
+
Always
@@ -94,7 +94,7 @@
Always
-
+
Always
@@ -103,7 +103,7 @@
Always
-
+
Always
diff --git a/FantasyPlayer.Mock/MockPlugin.cs b/FantasyPlayer.Mock/MockPlugin.cs
index 5bc8461..18fbeb9 100644
--- a/FantasyPlayer.Mock/MockPlugin.cs
+++ b/FantasyPlayer.Mock/MockPlugin.cs
@@ -11,7 +11,7 @@ namespace FantasyPlayer.Mock;
public class MockPlugin : Plugin
{
- public MockPlugin(IDalamudPluginInterface pluginInterface) : base(pluginInterface)
+public MockPlugin(IDalamudPluginInterface pluginInterface) : base(pluginInterface)
{
}
diff --git a/FantasyPlayer.Spotify/FantasyPlayer.Spotify.csproj b/FantasyPlayer.Spotify/FantasyPlayer.Spotify.csproj
index 13e743b..fd305bd 100644
--- a/FantasyPlayer.Spotify/FantasyPlayer.Spotify.csproj
+++ b/FantasyPlayer.Spotify/FantasyPlayer.Spotify.csproj
@@ -8,13 +8,20 @@
Copyright © 2020
false
bin\$(Configuration)\
+ AnyCPU;x64
full
+
+ full
+
pdbonly
+
+ pdbonly
+
diff --git a/FantasyPlayer.sln b/FantasyPlayer.sln
index 556f573..6a0fd00 100644
--- a/FantasyPlayer.sln
+++ b/FantasyPlayer.sln
@@ -1,5 +1,8 @@
Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 18
+VisualStudioVersion = 18.5.11619.145 insiders
+MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FantasyPlayer.Spotify", "FantasyPlayer.Spotify\FantasyPlayer.Spotify.csproj", "{64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FantasyPlayer", "FantasyPlayer\FantasyPlayer.csproj", "{7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}"
@@ -11,24 +14,45 @@ EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Debug|x64.ActiveCfg = Debug|x64
+ {64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Debug|x64.Build.0 = Debug|x64
{64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Release|Any CPU.ActiveCfg = Release|Any CPU
{64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Release|Any CPU.Build.0 = Release|Any CPU
+ {64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Release|x64.ActiveCfg = Release|x64
+ {64E9DE35-E1B3-467B-8AD5-E36BAB2A3F27}.Release|x64.Build.0 = Release|x64
{7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Debug|x64.ActiveCfg = Debug|x64
+ {7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Debug|x64.Build.0 = Debug|x64
{7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Release|x64.ActiveCfg = Release|x64
+ {7C8F3B5E-12F3-43F5-9080-B58471ABB7A1}.Release|x64.Build.0 = Release|x64
{DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Debug|x64.ActiveCfg = Debug|x64
+ {DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Debug|x64.Build.0 = Debug|x64
{DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Release|x64.ActiveCfg = Release|x64
+ {DFFAFA7A-9860-473E-BBD7-D39ACE6ED7EB}.Release|x64.Build.0 = Release|x64
{668953BC-B270-43F7-952B-706ABFDD6DEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{668953BC-B270-43F7-952B-706ABFDD6DEC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {668953BC-B270-43F7-952B-706ABFDD6DEC}.Debug|x64.ActiveCfg = Debug|x64
+ {668953BC-B270-43F7-952B-706ABFDD6DEC}.Debug|x64.Build.0 = Debug|x64
{668953BC-B270-43F7-952B-706ABFDD6DEC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{668953BC-B270-43F7-952B-706ABFDD6DEC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {668953BC-B270-43F7-952B-706ABFDD6DEC}.Release|x64.ActiveCfg = Release|x64
+ {668953BC-B270-43F7-952B-706ABFDD6DEC}.Release|x64.Build.0 = Release|x64
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
diff --git a/FantasyPlayer/Config/Configuration.cs b/FantasyPlayer/Config/Configuration.cs
index f981780..7aebd84 100644
--- a/FantasyPlayer/Config/Configuration.cs
+++ b/FantasyPlayer/Config/Configuration.cs
@@ -14,7 +14,7 @@ public class Configuration : IPluginConfiguration, INotifyPropertyChanged
private bool isDirty;
public bool IsDirty
{
- get => isDirty || PlayerSettings.IsDirty || SpotifySettings.IsDirty || AutoPlaySettings.IsDirty;
+ get => isDirty || PlayerSettings.IsDirty || SpotifySettings.IsDirty || AutoPlaySettings.IsDirty || LyricsSettings.IsDirty;
set => SetField(ref isDirty, value, false);
}
@@ -24,6 +24,7 @@ public void MarkClean()
PlayerSettings.MarkClean();
SpotifySettings.MarkClean();
AutoPlaySettings.MarkClean();
+ LyricsSettings.MarkClean();
}
private bool displayChatMessages;
private bool configShown;
@@ -32,6 +33,7 @@ public void MarkClean()
public PlayerSettings PlayerSettings { get; set; } = new PlayerSettings();
public SpotifySettings SpotifySettings { get; set; } = new SpotifySettings();
public AutoPlaySettings AutoPlaySettings { get; set; } = new AutoPlaySettings();
+ public LyricsSettings LyricsSettings { get; set; } = new LyricsSettings();
public bool DisplayChatMessages
{
diff --git a/FantasyPlayer/Config/LyricsSettings.cs b/FantasyPlayer/Config/LyricsSettings.cs
new file mode 100644
index 0000000..6de2d52
--- /dev/null
+++ b/FantasyPlayer/Config/LyricsSettings.cs
@@ -0,0 +1,77 @@
+using Dalamud.Game.Text;
+
+namespace FantasyPlayer.Config
+{
+ using System.Collections.Generic;
+ using System.ComponentModel;
+ using System.Runtime.CompilerServices;
+
+ public enum LyricDisplayMode
+ {
+ Chat = 0,
+ FlyText = 1,
+ }
+
+ public class LyricsSettings : INotifyPropertyChanged
+ {
+ private bool isDirty;
+ public bool IsDirty
+ {
+ get => isDirty;
+ private set => SetField(ref isDirty, value, false);
+ }
+
+ public void MarkClean()
+ {
+ IsDirty = false;
+ }
+
+ private bool enableLyrics;
+ public bool EnableLyrics
+ {
+ get => enableLyrics;
+ set => SetField(ref enableLyrics, value);
+ }
+
+ private LyricDisplayMode displayMode = LyricDisplayMode.Chat;
+ public LyricDisplayMode DisplayMode
+ {
+ get => displayMode;
+ set => SetField(ref displayMode, value);
+ }
+
+ private XivChatType chatType = XivChatType.Echo;
+ public XivChatType ChatType
+ {
+ get => chatType;
+ set => SetField(ref chatType, value);
+ }
+
+ private uint flyTextColor = 0xFF60D71E;
+ public uint FlyTextColor
+ {
+ get => flyTextColor;
+ set => SetField(ref flyTextColor, value);
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+
+ protected bool SetField(ref T field, T value, bool markDirty = true, [CallerMemberName] string? propertyName = null)
+ {
+ if (EqualityComparer.Default.Equals(field, value)) return false;
+ field = value;
+ OnPropertyChanged(propertyName);
+ if (markDirty)
+ {
+ IsDirty = true;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/FantasyPlayer/Interface/Window/SettingsWindow.cs b/FantasyPlayer/Interface/Window/SettingsWindow.cs
index 9200b43..29bf2d7 100644
--- a/FantasyPlayer/Interface/Window/SettingsWindow.cs
+++ b/FantasyPlayer/Interface/Window/SettingsWindow.cs
@@ -1,4 +1,5 @@
using Dalamud.Bindings.ImGui;
+using System;
using System.Numerics;
using AllaganLib.Shared.Extensions;
using Dalamud.Interface;
@@ -262,7 +263,59 @@ private void MainWindow()
}
}
+ if (ImGui.CollapsingHeader("Lyrics Settings"))
+ {
+ var enableLyrics = _configuration.LyricsSettings.EnableLyrics;
+ if (ImGui.Checkbox("Enable Lyrics", ref enableLyrics))
+ {
+ _configuration.LyricsSettings.EnableLyrics = enableLyrics;
+ }
+
+ var displayModeIndex = (int)_configuration.LyricsSettings.DisplayMode;
+ string[] displayModes = { "Chat", "Flytext" };
+ if (ImGui.Combo("Lyric Display Mode", ref displayModeIndex, displayModes, displayModes.Length))
+ {
+ _configuration.LyricsSettings.DisplayMode = (Config.LyricDisplayMode)displayModeIndex;
+ }
+
+ if (_configuration.LyricsSettings.DisplayMode == Config.LyricDisplayMode.Chat)
+ {
+ if (Widget.DrawChatTypeSelector("Lyrics chat output channel",
+ "To which chat channel should the lyrics be printed?",
+ _configuration.LyricsSettings.ChatType,
+ type => { _configuration.LyricsSettings.ChatType = type; }))
+ {
+ }
+ }
+ else
+ {
+ var lyricColor = AbgrToVector4(_configuration.LyricsSettings.FlyTextColor);
+ if (ImGui.ColorEdit4("Flytext Color", ref lyricColor))
+ {
+ _configuration.LyricsSettings.FlyTextColor = Vector4ToAbgr(lyricColor);
+ }
+ }
+ }
+
ImGui.Separator();
}
+
+ private static Vector4 AbgrToVector4(uint abgr)
+ {
+ var r = (abgr & 0xFF) / 255f;
+ var g = ((abgr >> 8) & 0xFF) / 255f;
+ var b = ((abgr >> 16) & 0xFF) / 255f;
+ var a = ((abgr >> 24) & 0xFF) / 255f;
+ return new Vector4(r, g, b, a);
+ }
+
+ private static uint Vector4ToAbgr(Vector4 color)
+ {
+ var r = (uint)Math.Clamp(color.X * 255f, 0f, 255f);
+ var g = (uint)Math.Clamp(color.Y * 255f, 0f, 255f);
+ var b = (uint)Math.Clamp(color.Z * 255f, 0f, 255f);
+ var a = (uint)Math.Clamp(color.W * 255f, 0f, 255f);
+ return (a << 24) | (b << 16) | (g << 8) | r;
+ }
}
}
\ No newline at end of file
diff --git a/FantasyPlayer/Lyrics/LrcLibResponse.cs b/FantasyPlayer/Lyrics/LrcLibResponse.cs
new file mode 100644
index 0000000..772c92b
--- /dev/null
+++ b/FantasyPlayer/Lyrics/LrcLibResponse.cs
@@ -0,0 +1,25 @@
+using System.Text.Json.Serialization;
+
+namespace FantasyPlayer.Lyrics
+{
+ public class LrcLibResponse
+ {
+ [JsonPropertyName("id")]
+ public int Id { get; set; }
+
+ [JsonPropertyName("trackName")]
+ public string? TrackName { get; set; }
+
+ [JsonPropertyName("artistName")]
+ public string? ArtistName { get; set; }
+
+ [JsonPropertyName("albumName")]
+ public string? AlbumName { get; set; }
+
+ [JsonPropertyName("duration")]
+ public double Duration { get; set; }
+
+ [JsonPropertyName("syncedLyrics")]
+ public string? SyncedLyrics { get; set; }
+ }
+}
diff --git a/FantasyPlayer/Lyrics/LyricsManager.cs b/FantasyPlayer/Lyrics/LyricsManager.cs
new file mode 100644
index 0000000..af797c8
--- /dev/null
+++ b/FantasyPlayer/Lyrics/LyricsManager.cs
@@ -0,0 +1,195 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Dalamud.Game.Gui.FlyText;
+using Dalamud.Game.Text;
+using Dalamud.Game.Text.SeStringHandling;
+using Dalamud.Game.Text.SeStringHandling.Payloads;
+using Dalamud.Plugin.Services;
+using FantasyPlayer.Config;
+using FantasyPlayer.Manager;
+using Microsoft.Extensions.Hosting;
+
+namespace FantasyPlayer.Lyrics
+{
+ public class LyricsManager : BackgroundService
+ {
+ private readonly IPluginLog _pluginLog;
+ private readonly Configuration _configuration;
+ private readonly PlayerManager _playerManager;
+ private readonly LyricsService _lyricsService;
+ private readonly IChatGui _chatGui;
+ private readonly IFlyTextGui _flyTextGui;
+ private readonly IFramework _framework;
+
+ private string? _lastTrackId;
+ private List<(TimeSpan Time, string Text)> _currentLyrics = new();
+ private int _lastLyricIndex = -1;
+ private bool _isFetching;
+
+ private int _lastProgressMs = -1;
+ private DateTime _lastProgressTime = DateTime.MinValue;
+
+ public LyricsManager(
+ IPluginLog pluginLog,
+ Configuration configuration,
+ PlayerManager playerManager,
+ LyricsService lyricsService,
+ IChatGui chatGui,
+ IFlyTextGui flyTextGui,
+ IFramework framework)
+ {
+ _pluginLog = pluginLog;
+ _configuration = configuration;
+ _playerManager = playerManager;
+ _lyricsService = lyricsService;
+ _chatGui = chatGui;
+ _flyTextGui = flyTextGui;
+ _framework = framework;
+ }
+
+ public override async Task StartAsync(CancellationToken cancellationToken)
+ {
+ _framework.Update += OnFrameworkUpdate;
+ await base.StartAsync(cancellationToken);
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ _framework.Update -= OnFrameworkUpdate;
+ await base.StopAsync(cancellationToken);
+ }
+
+ protected override Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ return Task.CompletedTask;
+ }
+
+ private void OnFrameworkUpdate(IFramework framework)
+ {
+ if (!_configuration.LyricsSettings.EnableLyrics)
+ return;
+
+ var provider = _playerManager.CurrentPlayerProvider;
+ if (provider == null)
+ return;
+
+ var state = provider.PlayerState;
+ if (!state.IsPlaying)
+ return;
+
+ var currentTrack = state.CurrentlyPlaying;
+ var trackId = currentTrack.Id;
+
+ if (trackId != _lastTrackId)
+ {
+ _lastTrackId = trackId;
+ _lastLyricIndex = -1;
+ _lastProgressMs = -1;
+ _lastProgressTime = DateTime.MinValue;
+ _currentLyrics = new List<(TimeSpan, string)>();
+
+ if (!string.IsNullOrEmpty(trackId) && !_isFetching)
+ {
+ var artist = currentTrack.Artists.Length > 0 ? currentTrack.Artists[0] : string.Empty;
+ _ = FetchLyricsAsync(artist, currentTrack.Name, currentTrack.Album.Name, currentTrack.DurationMs);
+ }
+ }
+
+ if (_currentLyrics.Count == 0)
+ return;
+
+ var apiProgressMs = state.ProgressMs;
+ var now = DateTime.UtcNow;
+
+ TimeSpan currentTime;
+ if (_lastProgressMs >= 0 && apiProgressMs == _lastProgressMs)
+ {
+ currentTime = TimeSpan.FromMilliseconds(_lastProgressMs) + (now - _lastProgressTime);
+ }
+ else
+ {
+ if (_lastProgressMs >= 0)
+ {
+ var expectedMs = _lastProgressMs + (int)(now - _lastProgressTime).TotalMilliseconds;
+ if (apiProgressMs < _lastProgressMs || Math.Abs(apiProgressMs - expectedMs) > 5000)
+ {
+ _lastLyricIndex = -1;
+ }
+ }
+
+ _lastProgressMs = apiProgressMs;
+ _lastProgressTime = now;
+ currentTime = TimeSpan.FromMilliseconds(apiProgressMs);
+ }
+
+ for (var i = _lastLyricIndex + 1; i < _currentLyrics.Count; i++)
+ {
+ var lyric = _currentLyrics[i];
+ if (currentTime < lyric.Time)
+ break;
+
+ var isLastLine = i + 1 >= _currentLyrics.Count;
+ var beforeNext = isLastLine || currentTime < _currentLyrics[i + 1].Time;
+
+ if (beforeNext)
+ {
+ DisplayLyric(lyric.Text);
+ _lastLyricIndex = i;
+ break;
+ }
+ }
+ }
+
+ private async Task FetchLyricsAsync(string artist, string title, string album, int durationMs)
+ {
+ _isFetching = true;
+ try
+ {
+ var lyrics = await _lyricsService.FetchSyncedLyricsAsync(artist, title, album, durationMs);
+ _currentLyrics = lyrics;
+ _lastLyricIndex = -1;
+ }
+ catch (Exception ex)
+ {
+ _pluginLog.Error($"Failed to fetch lyrics: {ex.Message}");
+ }
+ finally
+ {
+ _isFetching = false;
+ }
+ }
+
+ private void DisplayLyric(string text)
+ {
+ try
+ {
+ var line = $"♪ {text}";
+ if (_configuration.LyricsSettings.DisplayMode == LyricDisplayMode.FlyText)
+ {
+ _flyTextGui.AddFlyText(
+ FlyTextKind.Named,
+ 1, 0, 0,
+ new SeString(new List { new TextPayload(line) }),
+ SeString.Empty,
+ _configuration.LyricsSettings.FlyTextColor,
+ 0, 0);
+ }
+ else
+ {
+ _chatGui.Print(new XivChatEntry
+ {
+ Message = new SeString(new List { new TextPayload(line) }),
+ Name = SeString.Empty,
+ Type = _configuration.LyricsSettings.ChatType,
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ _pluginLog.Error($"Failed to display lyric: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/FantasyPlayer/Lyrics/LyricsService.cs b/FantasyPlayer/Lyrics/LyricsService.cs
new file mode 100644
index 0000000..0222540
--- /dev/null
+++ b/FantasyPlayer/Lyrics/LyricsService.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Dalamud.Plugin.Services;
+
+namespace FantasyPlayer.Lyrics
+{
+ public class LyricsService : IDisposable
+ {
+ private static readonly HttpClient HttpClient = new HttpClient();
+ private static readonly Regex LrcLineRegex = new Regex(@"\[(\d+):(\d+)\.(\d+)\](.*)", RegexOptions.Compiled);
+
+ private readonly IPluginLog _pluginLog;
+
+ public LyricsService(IPluginLog pluginLog)
+ {
+ _pluginLog = pluginLog;
+ HttpClient.DefaultRequestHeaders.UserAgent.TryParseAdd("FantasyPlayer/1.0");
+ }
+
+ public async Task> FetchSyncedLyricsAsync(
+ string artist, string title, string album, int durationMs)
+ {
+ try
+ {
+ var durationSeconds = durationMs / 1000;
+ var url = $"https://lrclib.net/api/get" +
+ $"?artist_name={Uri.EscapeDataString(artist)}" +
+ $"&track_name={Uri.EscapeDataString(title)}" +
+ $"&album_name={Uri.EscapeDataString(album)}" +
+ $"&duration={durationSeconds}";
+
+ var response = await HttpClient.GetAsync(url);
+ if (!response.IsSuccessStatusCode)
+ {
+ _pluginLog.Debug($"LRCLIB returned {(int)response.StatusCode} for {artist} - {title}");
+ return new List<(TimeSpan, string)>();
+ }
+
+ var content = await response.Content.ReadAsStringAsync();
+ var lrcResponse = JsonSerializer.Deserialize(content);
+ if (lrcResponse?.SyncedLyrics == null)
+ {
+ _pluginLog.Debug($"No synced lyrics found for {artist} - {title}");
+ return new List<(TimeSpan, string)>();
+ }
+
+ var lines = ParseLrc(lrcResponse.SyncedLyrics);
+ _pluginLog.Debug($"Parsed {lines.Count} lyric lines for {artist} - {title}");
+ return lines;
+ }
+ catch (Exception ex)
+ {
+ _pluginLog.Error($"Failed to fetch lyrics from LRCLIB: {ex.Message}");
+ return new List<(TimeSpan, string)>();
+ }
+ }
+
+ private static List<(TimeSpan Time, string Text)> ParseLrc(string lrc)
+ {
+ var lines = new List<(TimeSpan, string)>();
+ foreach (var rawLine in lrc.Split('\n'))
+ {
+ var match = LrcLineRegex.Match(rawLine.Trim());
+ if (!match.Success) continue;
+
+ var minutes = int.Parse(match.Groups[1].Value);
+ var seconds = int.Parse(match.Groups[2].Value);
+ var centiseconds = int.Parse(match.Groups[3].Value);
+ var text = match.Groups[4].Value.Trim();
+
+ if (string.IsNullOrWhiteSpace(text)) continue;
+
+ var time = new TimeSpan(0, 0, minutes, seconds, centiseconds * 10);
+ lines.Add((time, text));
+ }
+
+ return lines;
+ }
+
+ public void Dispose()
+ {
+ }
+ }
+}
diff --git a/FantasyPlayer/Plugin.cs b/FantasyPlayer/Plugin.cs
index 939a07b..63d9a98 100644
--- a/FantasyPlayer/Plugin.cs
+++ b/FantasyPlayer/Plugin.cs
@@ -3,6 +3,7 @@
using FantasyPlayer.Config;
using FantasyPlayer.Interface;
using FantasyPlayer.Interfaces;
+using FantasyPlayer.Lyrics;
using FantasyPlayer.Manager;
namespace FantasyPlayer
@@ -21,7 +22,7 @@ namespace FantasyPlayer
public class Plugin : HostedPlugin
{
- public Plugin(IDalamudPluginInterface pluginInterface) : base(pluginInterface)
+public Plugin(IDalamudPluginInterface pluginInterface) : base(pluginInterface)
{
CreateHost();
Start();
@@ -54,6 +55,8 @@ public override void ConfigureContainer(ContainerBuilder containerBuilder)
containerBuilder.RegisterType().As().SingleInstance();
containerBuilder.RegisterType().As().SingleInstance();
containerBuilder.RegisterType().SingleInstance();
+ containerBuilder.RegisterType().SingleInstance();
+ containerBuilder.RegisterType().SingleInstance();
containerBuilder.RegisterType().As().SingleInstance();
containerBuilder.RegisterType().As().SingleInstance();
}
@@ -65,6 +68,7 @@ public override void ConfigureServices(IServiceCollection serviceCollection)
serviceCollection.AddHostedService(p => p.GetRequiredService());
serviceCollection.AddHostedService(p => p.GetRequiredService());
serviceCollection.AddHostedService(p => p.GetRequiredService());
+ serviceCollection.AddHostedService(p => p.GetRequiredService());
serviceCollection.AddHostedService(p => p.GetRequiredService());
}
}
diff --git a/FantasyPlayer/Provider/SpotifyProvider.cs b/FantasyPlayer/Provider/SpotifyProvider.cs
index c63068e..0e2a422 100644
--- a/FantasyPlayer/Provider/SpotifyProvider.cs
+++ b/FantasyPlayer/Provider/SpotifyProvider.cs
@@ -132,6 +132,16 @@ private void OnLoggedIn(PrivateUser privateUser, PKCETokenResponse tokenResponse
public void Update()
{
+ if (_spotifyState?.CurrentlyPlaying != null)
+ {
+ var progressMs = _spotifyState.CurrentlyPlaying.ProgressMs;
+ if (PlayerState.ProgressMs != progressMs)
+ {
+ var playerStateStruct = PlayerState;
+ playerStateStruct.ProgressMs = progressMs;
+ PlayerState = playerStateStruct;
+ }
+ }
}
public void ReAuth()
diff --git a/OtterGui b/OtterGui
index 79771ee..ff1e654 160000
--- a/OtterGui
+++ b/OtterGui
@@ -1 +1 @@
-Subproject commit 79771ee5f3d463f02c63bebbedaa0aff49e59718
+Subproject commit ff1e6543845e3b8c53a5f8b240bc38faffb1b3bf
From a230eb97d51b58259eda5b33c911b5b83a0ece81 Mon Sep 17 00:00:00 2001
From: Emma <153868115+GayPotatoEmma@users.noreply.github.com>
Date: Sat, 2 May 2026 14:51:43 +0200
Subject: [PATCH 3/4] update ottergui
---
OtterGui | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/OtterGui b/OtterGui
index ff1e654..79771ee 160000
--- a/OtterGui
+++ b/OtterGui
@@ -1 +1 @@
-Subproject commit ff1e6543845e3b8c53a5f8b240bc38faffb1b3bf
+Subproject commit 79771ee5f3d463f02c63bebbedaa0aff49e59718
From 16d3ee861ab4dc4369ef45b1c1e176f6d0a1dedb Mon Sep 17 00:00:00 2001
From: Emma <153868115+GayPotatoEmma@users.noreply.github.com>
Date: Sat, 2 May 2026 15:00:42 +0200
Subject: [PATCH 4/4] rebase
---
FantasyPlayer.Mock/MockPlugin.cs | 5 ++---
FantasyPlayer/Plugin.cs | 7 +------
2 files changed, 3 insertions(+), 9 deletions(-)
diff --git a/FantasyPlayer.Mock/MockPlugin.cs b/FantasyPlayer.Mock/MockPlugin.cs
index 24f3bd5..4395745 100644
--- a/FantasyPlayer.Mock/MockPlugin.cs
+++ b/FantasyPlayer.Mock/MockPlugin.cs
@@ -5,18 +5,17 @@ namespace FantasyPlayer.Mock;
using DalaMock.Core.Windows;
using DalaMock.Shared.Interfaces;
using Dalamud.Plugin;
-using Dalamud.Plugin.Services;
public class MockPlugin : Plugin
{
- public MockPlugin(IDalamudPluginInterface pluginInterface, IPluginLog pluginLog, IFramework framework, IClientState clientState, IChatGui chatGui, ICommandManager commandManager, ICondition condition, IFlyTextGui flyTextGui) : base(pluginInterface, pluginLog, framework, clientState, chatGui, commandManager, condition, flyTextGui)
+ public MockPlugin(IDalamudPluginInterface pluginInterface) : base(pluginInterface)
{
}
public override void ConfigureContainer(ContainerBuilder containerBuilder)
{
base.ConfigureContainer(containerBuilder);
- containerBuilder.RegisterType().As();
+ containerBuilder.RegisterType().SingleInstance();
containerBuilder.RegisterType().As();
}
}
\ No newline at end of file
diff --git a/FantasyPlayer/Plugin.cs b/FantasyPlayer/Plugin.cs
index 698a489..f5d8840 100644
--- a/FantasyPlayer/Plugin.cs
+++ b/FantasyPlayer/Plugin.cs
@@ -1,5 +1,4 @@
using Dalamud.Plugin;
-using Dalamud.Plugin.Services;
using FantasyPlayer.Config;
using FantasyPlayer.Interface;
using FantasyPlayer.Interfaces;
@@ -22,11 +21,8 @@ namespace FantasyPlayer
public class Plugin : HostedPlugin
{
- private readonly IFlyTextGui _flyTextGui;
-
- public Plugin(IDalamudPluginInterface pluginInterface, IPluginLog pluginLog, IFramework framework, IClientState clientState, IChatGui chatGui, ICommandManager commandManager, ICondition condition, IFlyTextGui flyTextGui) : base(pluginInterface, pluginLog, framework, clientState, chatGui, commandManager, condition)
+ public Plugin(IDalamudPluginInterface pluginInterface) : base(pluginInterface)
{
- _flyTextGui = flyTextGui;
CreateHost();
Start();
}
@@ -41,7 +37,6 @@ public override HostedPluginOptions ConfigureOptions()
public override void ConfigureContainer(ContainerBuilder containerBuilder)
{
- containerBuilder.RegisterInstance(_flyTextGui).As().SingleInstance();
containerBuilder.RegisterType().SingleInstance();
containerBuilder.Register(provider =>
{