From cdce44fb05f64971e978d9bd5520914c946b89e2 Mon Sep 17 00:00:00 2001 From: Toby Shi Date: Sat, 10 Jan 2026 01:51:00 +0800 Subject: [PATCH 1/2] add furigana to karaoke view --- Localizable.xcstrings | 26 +++++++ Lyric Fever.xcodeproj/project.pbxproj | 25 +++++-- LyricFever/KaraokeSettings.swift | 5 ++ LyricFever/LyricFever.swift | 3 + .../RomanizerService/RomanizerService.swift | 18 +++++ .../Support Files/UserDefaultStorage.swift | 1 + LyricFever/ViewModel.swift | 24 ++++++- .../Views/KaraokeView/KaraokeView.swift | 34 ++++++++++ .../Views/KaraokeView/RubyTextLabel.swift | 67 +++++++++++++++++++ 9 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 LyricFever/Views/KaraokeView/RubyTextLabel.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index b604e7b..239b2e9 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -336,6 +336,7 @@ } }, "Arist Name" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1442,6 +1443,7 @@ } }, "More options" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1464,6 +1466,7 @@ } }, "More options, AirPlay delay enabled" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -2337,6 +2340,29 @@ } } }, + "Show furigana in Karaoke window" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在 K 歌歌词窗口显示日语假名注音" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "當翻譯開啟時,在卡拉 OK 視窗顯示日語注音" + } + }, + "zh-Hant-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "卡拉 OK 視窗翻譯時顯示日語注音" + } + } + } + }, "Show lyrics (⌘ + H)" : { "localizations" : { "zh-Hans" : { diff --git a/Lyric Fever.xcodeproj/project.pbxproj b/Lyric Fever.xcodeproj/project.pbxproj index 30a7ce4..5d47d89 100644 --- a/Lyric Fever.xcodeproj/project.pbxproj +++ b/Lyric Fever.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ 8FFA9F382AA1B63500BAEC5C /* crossfade.gif in Resources */ = {isa = PBXBuildFile; fileRef = 8FFA9F362AA1B63500BAEC5C /* crossfade.gif */; }; 8FFB32DF2E694991007D9981 /* CompactSlider in Frameworks */ = {isa = PBXBuildFile; productRef = 8FFB32DE2E694991007D9981 /* CompactSlider */; }; 8FFED07B2D8A26E700EA1510 /* SwiftOTP in Frameworks */ = {isa = PBXBuildFile; productRef = 8FFED07A2D8A26E700EA1510 /* SwiftOTP */; }; + DAABCADB2F11782900B369BE /* SwiftCF in Frameworks */ = {isa = PBXBuildFile; productRef = DAABCADA2F11782900B369BE /* SwiftCF */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -102,6 +103,7 @@ 8F8196872D93CCDC00F03741 /* StringMetric in Frameworks */, 8FFA9F342AA1B3CB00BAEC5C /* SDWebImageSwiftUI in Frameworks */, 8F89E1112C90DA4900F511EB /* LaunchAtLogin in Frameworks */, + DAABCADB2F11782900B369BE /* SwiftCF in Frameworks */, 8F2922C02E94DE0700B286FC /* MediaRemoteAdapter in Frameworks */, 8F6BD2952A8A61C9008BBF88 /* AmplitudeSwift in Frameworks */, ); @@ -200,6 +202,7 @@ 8F37323C2E6EC9C90075CDA7 /* OpenCC */, 8F1198712E72B9840011A962 /* KeyboardShortcuts */, 8F2922BF2E94DE0700B286FC /* MediaRemoteAdapter */, + DAABCADA2F11782900B369BE /* SwiftCF */, ); productName = SpotifyLyricsInMenubar; productReference = 8FC8E9452A704EEB00F69915 /* Lyric Fever.app */; @@ -246,6 +249,7 @@ 8F37323B2E6EC9C90075CDA7 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */, 8F1198702E72B9840011A962 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */, 8F2922BE2E94DE0700B286FC /* XCRemoteSwiftPackageReference "mediaremote-adapter" */, + DAABCAD92F11782900B369BE /* XCRemoteSwiftPackageReference "SwiftCF" */, ); productRefGroup = 8FC8E9462A704EEB00F69915 /* Products */; projectDirPath = ""; @@ -421,7 +425,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3.2; + CURRENT_PROJECT_VERSION = 3.3; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = 38TP6LZLJ5; @@ -442,7 +446,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.3; PRODUCT_BUNDLE_IDENTIFIER = com.aviwadhwa.SpotifyLyricsInMenubar; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -460,7 +464,7 @@ "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 3.2; + CURRENT_PROJECT_VERSION = 3.3; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = 38TP6LZLJ5; @@ -481,7 +485,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.3; PRODUCT_BUNDLE_IDENTIFIER = com.aviwadhwa.SpotifyLyricsInMenubar; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -626,6 +630,14 @@ minimumVersion = 3.0.2; }; }; + DAABCAD92F11782900B369BE /* XCRemoteSwiftPackageReference "SwiftCF" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ddddxxx/SwiftCF"; + requirement = { + branch = master; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -704,6 +716,11 @@ package = 8FFED0792D8A26E700EA1510 /* XCRemoteSwiftPackageReference "SwiftOTP" */; productName = SwiftOTP; }; + DAABCADA2F11782900B369BE /* SwiftCF */ = { + isa = XCSwiftPackageProductDependency; + package = DAABCAD92F11782900B369BE /* XCRemoteSwiftPackageReference "SwiftCF" */; + productName = SwiftCF; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 8FC8E93D2A704EEB00F69915 /* Project object */; diff --git a/LyricFever/KaraokeSettings.swift b/LyricFever/KaraokeSettings.swift index 86557cf..561bbab 100644 --- a/LyricFever/KaraokeSettings.swift +++ b/LyricFever/KaraokeSettings.swift @@ -14,6 +14,7 @@ struct KaraokeSettingsView: View { @Environment(ViewModel.self) var viewmodel @AppStorage("karaokeModeHoveringSetting") var karaokeModeHoveringSetting: Bool = false @AppStorage("karaokeShowMultilingual") var karaokeShowMultilingual: Bool = true + @AppStorage("karaokeShowFurigana") var karaokeShowFurigana: Bool = false @AppStorage("karaokeTransparency") var karaokeTransparency: Double = 50 var body: some View { @@ -29,6 +30,10 @@ struct KaraokeSettingsView: View { Text("Show multilingual lyrics when translating in Karaoke window") } .toggleStyle(.checkbox) + Toggle(isOn: $karaokeShowFurigana) { + Text("Show furigana in Karaoke window") + } + .toggleStyle(.checkbox) .padding(.bottom, 20) Text("Karaoke Background Appearance") diff --git a/LyricFever/LyricFever.swift b/LyricFever/LyricFever.swift index be7cf87..2899766 100644 --- a/LyricFever/LyricFever.swift +++ b/LyricFever/LyricFever.swift @@ -122,6 +122,9 @@ struct LyricFever: App { .onChange(of: viewmodel.userDefaultStorage.romanize) { viewmodel.romanizeDidChange() } + .onChange(of: viewmodel.userDefaultStorage.karaokeShowFurigana) { + viewmodel.furiganaDidChange() + } // .onChange(of: viewmodel.userDefaultStorage.romanizeMetadata) { // if viewmodel.userDefaultStorage.romanizeMetadata { // viewmodel.romanizeMetadata() diff --git a/LyricFever/Services/RomanizerService/RomanizerService.swift b/LyricFever/Services/RomanizerService/RomanizerService.swift index 047922a..495f1a0 100644 --- a/LyricFever/Services/RomanizerService/RomanizerService.swift +++ b/LyricFever/Services/RomanizerService/RomanizerService.swift @@ -18,6 +18,8 @@ import Mecab_Swift import IPADic import OpenCC +typealias FuriganaAnnotation = Annotation + class RomanizerService { private static func generateJapaneseRomanizedString(_ string: String) -> String? { let ipadic=IPADic() @@ -38,6 +40,22 @@ class RomanizerService { } } + private static func generateJapaneseFuriganaAnnotations(_ string: String) -> [FuriganaAnnotation]? { + let ipadic = IPADic() + let ipadicTokenizer = try? Tokenizer(dictionary: ipadic) + guard let furigana = ipadicTokenizer?.tokenize(text: string, transliteration: .hiragana) else { + return nil + } + return furigana.filter({$0.base != $0.reading}) + } + static func generateFuriganaLyric(_ lyric: LyricLine) -> [FuriganaAnnotation]? { + print("Generating Furigana String for lyric \(lyric.words)") + guard let language = NLLanguageRecognizer.dominantLanguage(for: lyric.words), language == .japanese else { + return [] + } + return generateJapaneseFuriganaAnnotations(lyric.words) + } + static func generateRomanizedString(_ string: String) -> String? { print("Generating Romanized String for string \(string)") if let language = NLLanguageRecognizer.dominantLanguage(for: string), language == .japanese { diff --git a/LyricFever/Support Files/UserDefaultStorage.swift b/LyricFever/Support Files/UserDefaultStorage.swift index 052d3e8..8d9be1d 100644 --- a/LyricFever/Support Files/UserDefaultStorage.swift +++ b/LyricFever/Support Files/UserDefaultStorage.swift @@ -34,6 +34,7 @@ class UserDefaultStorage { var karaoke: Bool = true var karaokeUseAlbumColor: Bool = true var karaokeShowMultilingual: Bool = true + var karaokeShowFurigana: Bool = false var karaokeTransparency: Double = 50 var fixedKaraokeColorHex: String = "#2D3CCC" diff --git a/LyricFever/ViewModel.swift b/LyricFever/ViewModel.swift index 3fa43b9..e719dea 100644 --- a/LyricFever/ViewModel.swift +++ b/LyricFever/ViewModel.swift @@ -55,7 +55,7 @@ import MediaRemoteAdapter // } else { // self.appleMusicUniqueIdentifier = data.payload.uniqueIdentifier // } - guard let artwork = data.payload.artwork else { + guard let artwork = data?.payload.artwork else { if self.currentlyPlaying == nil { self.artworkImage = nil } @@ -170,6 +170,7 @@ import MediaRemoteAdapter var currentlyPlayingLyricsIndex: Int? var isPlaying: Bool = false var romanizedLyrics: [String] = [] + var furiganaAnnotaions: [[FuriganaAnnotation]] = [] var chineseConversionLyrics: [String] = [] var translatedLyric: [String] = [] var showLyrics = true @@ -544,6 +545,26 @@ import MediaRemoteAdapter } } + func furiganaDidChange() { + if userDefaultStorage.karaokeShowFurigana { + // Generate romanized lyrics from chinese conversion + if !chineseConversionLyrics.isEmpty { + print("Furigana Annotated Lyrics generated from romanize value change for song \(currentlyPlaying) with chinese conversion") + furiganaAnnotaions = chineseConversionLyrics.compactMap({ + RomanizerService.generateFuriganaLyric(LyricLine(startTime: 0, words: $0)) + }) + // Generate romanized lyrics from original lyrics + } else { + print("Furigana Annotated Lyrics generated from romanize value change for song \(currentlyPlaying)") + furiganaAnnotaions = currentlyPlayingLyrics.compactMap({ + RomanizerService.generateFuriganaLyric($0) + }) + } + } else { + furiganaAnnotaions = [] + } + } + // Only called when Romanize is true // func romanizeMetadata() { // // Generate romanized metadata from name & artist @@ -1037,6 +1058,7 @@ import MediaRemoteAdapter chinesePreferenceDidChange() // we romanize afterwards, in-case the chinese conversion array was populated romanizeDidChange() + furiganaDidChange() lyricsIsEmptyPostLoad = currentlyPlayingLyrics.isEmpty if isPlaying, !currentlyPlayingLyrics.isEmpty, showLyrics, userDefaultStorage.hasOnboarded { startLyricUpdater() diff --git a/LyricFever/Views/KaraokeView/KaraokeView.swift b/LyricFever/Views/KaraokeView/KaraokeView.swift index d2231f4..c89d1d5 100644 --- a/LyricFever/Views/KaraokeView/KaraokeView.swift +++ b/LyricFever/Views/KaraokeView/KaraokeView.swift @@ -9,6 +9,7 @@ import SwiftUI import SDWebImage import ColorKit import Combine +import CoreTextExt struct VisualEffectView: NSViewRepresentable { func makeNSView(context: Context) -> NSVisualEffectView { @@ -52,6 +53,37 @@ struct KaraokeView: View { } } + func furiganaView(_ currentlyPlayingLyricsIndex: Int) -> some View { + let origWords = viewmodel.currentlyPlayingLyrics[currentlyPlayingLyricsIndex].words + let attrString = NSMutableAttributedString(attributedString: NSAttributedString(string: origWords)) + + // Add paragraph style to increase line height for Furigana and ensure centering + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = 1.2 + paragraphStyle.alignment = .center + attrString.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: attrString.length)) + + for furigana in viewmodel.furiganaAnnotaions[currentlyPlayingLyricsIndex] { + let attr: [CFAttributedString.Key: Any] = [.ctRubySizeFactor: 0.5] + let annotation = CTRubyAnnotation.create(furigana.reading as NSString as CFString, attributes: attr) + attrString.addAttribute(.cf(.ctRubyAnnotation), value: annotation, range: NSRange(furigana.range, in: origWords)) + } + + // Construct the NSFont from the viewmodel to match .font(.custom(...)) + // Scale down slightly (0.85x) to accommodate Furigana vertical height within the fixed 100pt frame + let fontSize = viewmodel.karaokeFont.pointSize * 0.85 + let nsFont = NSFont(name: viewmodel.karaokeFont.fontName, size: fontSize) + ?? NSFont.systemFont(ofSize: fontSize) + + return RubyTextLabel( + attributedText: attrString, + font: nsFont, + textColor: .white, // Matches .foregroundStyle(.white) + textAlignment: .center, // Matches .multilineTextAlignment(.center) + lineLimit: 2 // Matches .lineLimit(2) + ) + } + func originalAndTranslationAreDifferent(for currentlyPlayingLyricsIndex: Int) -> Bool { viewmodel.currentlyPlayingLyrics[currentlyPlayingLyricsIndex].words != viewmodel.translatedLyric[currentlyPlayingLyricsIndex] } @@ -71,6 +103,8 @@ struct KaraokeView: View { } else { if !viewmodel.romanizedLyrics.isEmpty { Text(verbatim: viewmodel.romanizedLyrics[currentlyPlayingLyricsIndex]) + } else if !viewmodel.furiganaAnnotaions.isEmpty { + furiganaView(currentlyPlayingLyricsIndex) } else if !viewmodel.chineseConversionLyrics.isEmpty { Text(verbatim: viewmodel.chineseConversionLyrics[currentlyPlayingLyricsIndex]) } else { diff --git a/LyricFever/Views/KaraokeView/RubyTextLabel.swift b/LyricFever/Views/KaraokeView/RubyTextLabel.swift new file mode 100644 index 0000000..37fd8fa --- /dev/null +++ b/LyricFever/Views/KaraokeView/RubyTextLabel.swift @@ -0,0 +1,67 @@ +// +// RubyTextLabel.swift +// Lyric Fever +// +// Created by Toby on 2026/1/10. +// + +import SwiftUI +import AppKit + +struct RubyTextLabel: NSViewRepresentable { + var attributedText: NSAttributedString + var font: NSFont + var textColor: NSColor + var textAlignment: NSTextAlignment + var lineLimit: Int + + init(attributedText: NSAttributedString, + font: NSFont = .systemFont(ofSize: 20), + textColor: NSColor = .white, + textAlignment: NSTextAlignment = .center, + lineLimit: Int = 2) { + self.attributedText = attributedText + self.font = font + self.textColor = textColor + self.textAlignment = textAlignment + self.lineLimit = lineLimit + } + + func makeNSView(context: Context) -> NSTextField { + // NSTextField configured as a label is the AppKit equivalent of UILabel + let label = NSTextField(labelWithAttributedString: attributedText) + label.isEditable = false + label.isSelectable = false + label.isBezeled = false + label.drawsBackground = false + + // Ensure the label hugs its content horizontally so the SwiftUI background + // doesn't expand beyond the text width. + label.setContentHuggingPriority(.defaultHigh, for: .horizontal) + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + return label + } + + func updateNSView(_ nsView: NSTextField, context: Context) { + nsView.attributedStringValue = attributedText + nsView.font = font + nsView.textColor = textColor + nsView.alignment = textAlignment + + // Configuration for multi-line support + if lineLimit == 1 { + nsView.usesSingleLineMode = true + nsView.cell?.wraps = false + nsView.cell?.isScrollable = false + nsView.maximumNumberOfLines = 1 + nsView.cell?.lineBreakMode = .byTruncatingTail + } else { + nsView.usesSingleLineMode = false + nsView.cell?.wraps = true + nsView.cell?.isScrollable = false + nsView.maximumNumberOfLines = lineLimit + nsView.cell?.lineBreakMode = .byWordWrapping + } + } +} From 79c84b4b0011f9ea5429be16f38794521fd68821 Mon Sep 17 00:00:00 2001 From: Toby Shi Date: Sat, 10 Jan 2026 02:23:43 +0800 Subject: [PATCH 2/2] fix: Normal size if no furigana in one LyricsLine --- LyricFever/Views/KaraokeView/KaraokeView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LyricFever/Views/KaraokeView/KaraokeView.swift b/LyricFever/Views/KaraokeView/KaraokeView.swift index c89d1d5..90c4bc5 100644 --- a/LyricFever/Views/KaraokeView/KaraokeView.swift +++ b/LyricFever/Views/KaraokeView/KaraokeView.swift @@ -103,7 +103,7 @@ struct KaraokeView: View { } else { if !viewmodel.romanizedLyrics.isEmpty { Text(verbatim: viewmodel.romanizedLyrics[currentlyPlayingLyricsIndex]) - } else if !viewmodel.furiganaAnnotaions.isEmpty { + } else if !viewmodel.furiganaAnnotaions.isEmpty && !viewmodel.furiganaAnnotaions[currentlyPlayingLyricsIndex].isEmpty { furiganaView(currentlyPlayingLyricsIndex) } else if !viewmodel.chineseConversionLyrics.isEmpty { Text(verbatim: viewmodel.chineseConversionLyrics[currentlyPlayingLyricsIndex])