Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@
}
},
"Arist Name" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
Expand Down Expand Up @@ -1442,6 +1443,7 @@
}
},
"More options" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
Expand All @@ -1464,6 +1466,7 @@
}
},
"More options, AirPlay delay enabled" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
Expand Down Expand Up @@ -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" : {
Expand Down
25 changes: 21 additions & 4 deletions Lyric Fever.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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 */,
);
Expand Down Expand Up @@ -200,6 +202,7 @@
8F37323C2E6EC9C90075CDA7 /* OpenCC */,
8F1198712E72B9840011A962 /* KeyboardShortcuts */,
8F2922BF2E94DE0700B286FC /* MediaRemoteAdapter */,
DAABCADA2F11782900B369BE /* SwiftCF */,
);
productName = SpotifyLyricsInMenubar;
productReference = 8FC8E9452A704EEB00F69915 /* Lyric Fever.app */;
Expand Down Expand Up @@ -246,6 +249,7 @@
8F37323B2E6EC9C90075CDA7 /* XCRemoteSwiftPackageReference "SwiftyOpenCC" */,
8F1198702E72B9840011A962 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */,
8F2922BE2E94DE0700B286FC /* XCRemoteSwiftPackageReference "mediaremote-adapter" */,
DAABCAD92F11782900B369BE /* XCRemoteSwiftPackageReference "SwiftCF" */,
);
productRefGroup = 8FC8E9462A704EEB00F69915 /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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 */;
Expand Down
5 changes: 5 additions & 0 deletions LyricFever/KaraokeSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
Expand Down
3 changes: 3 additions & 0 deletions LyricFever/LyricFever.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
18 changes: 18 additions & 0 deletions LyricFever/Services/RomanizerService/RomanizerService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions LyricFever/Support Files/UserDefaultStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
24 changes: 23 additions & 1 deletion LyricFever/ViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
34 changes: 34 additions & 0 deletions LyricFever/Views/KaraokeView/KaraokeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import SwiftUI
import SDWebImage
import ColorKit
import Combine
import CoreTextExt

struct VisualEffectView: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
Expand Down Expand Up @@ -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]
}
Expand All @@ -71,6 +103,8 @@ struct KaraokeView: View {
} else {
if !viewmodel.romanizedLyrics.isEmpty {
Text(verbatim: viewmodel.romanizedLyrics[currentlyPlayingLyricsIndex])
} else if !viewmodel.furiganaAnnotaions.isEmpty && !viewmodel.furiganaAnnotaions[currentlyPlayingLyricsIndex].isEmpty {
furiganaView(currentlyPlayingLyricsIndex)
} else if !viewmodel.chineseConversionLyrics.isEmpty {
Text(verbatim: viewmodel.chineseConversionLyrics[currentlyPlayingLyricsIndex])
} else {
Expand Down
67 changes: 67 additions & 0 deletions LyricFever/Views/KaraokeView/RubyTextLabel.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}