Tokenize Spanish lyric lines into a flow layout of underlined, long-pressable words. Long-press (0.35s) opens a sheet with base form, English, part of speech, and a Tense · person row for verbs. Unknown words silently no-op. English gloss lines remain untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
275 lines
9.0 KiB
Swift
275 lines
9.0 KiB
Swift
import SwiftUI
|
|
import SharedModels
|
|
|
|
struct LyricsReaderView: View {
|
|
let song: SavedSong
|
|
|
|
@Environment(DictionaryService.self) private var dictionary
|
|
@State private var selectedWord: LyricsWordLookup?
|
|
@State private var lookupCache: [String: LyricsWordLookup] = [:]
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
headerSection
|
|
lyricsBody
|
|
}
|
|
.padding()
|
|
.adaptiveContainer()
|
|
}
|
|
.navigationTitle(song.title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.sheet(item: $selectedWord) { word in
|
|
LyricsWordDetailSheet(word: word)
|
|
.presentationDetents([.height(260)])
|
|
}
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private var headerSection: some View {
|
|
VStack(spacing: 10) {
|
|
if !song.albumArtURL.isEmpty, let url = URL(string: song.albumArtURL) {
|
|
AsyncImage(url: url) { image in
|
|
image.resizable().scaledToFill()
|
|
} placeholder: {
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(.fill.quaternary)
|
|
}
|
|
.frame(width: 160, height: 160)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
Text(song.title)
|
|
.font(.title2.weight(.bold))
|
|
.multilineTextAlignment(.center)
|
|
|
|
Text(song.artist)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if !song.appleMusicURL.isEmpty, let url = URL(string: song.appleMusicURL) {
|
|
Link(destination: url) {
|
|
Label("Open in Apple Music", systemImage: "apple.logo")
|
|
.font(.caption.weight(.medium))
|
|
}
|
|
.tint(.pink)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Lyrics Body
|
|
|
|
private var lyricsBody: some View {
|
|
let spanishLines = song.lyricsES.components(separatedBy: "\n")
|
|
let englishLines = song.lyricsEN.components(separatedBy: "\n")
|
|
let lineCount = max(spanishLines.count, englishLines.count)
|
|
|
|
return VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(0..<lineCount, id: \.self) { index in
|
|
let es = index < spanishLines.count ? spanishLines[index] : ""
|
|
let en = index < englishLines.count ? englishLines[index] : ""
|
|
|
|
if es.trimmingCharacters(in: .whitespaces).isEmpty &&
|
|
en.trimmingCharacters(in: .whitespaces).isEmpty {
|
|
// Blank line = section divider
|
|
Spacer().frame(height: 20)
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
if !es.isEmpty {
|
|
spanishLine(es)
|
|
}
|
|
if !en.isEmpty {
|
|
Text(en)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.background(.fill.quinary, in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
|
|
private func spanishLine(_ line: String) -> some View {
|
|
let tokens = line.components(separatedBy: " ")
|
|
return LyricsFlowLayout(spacing: 0) {
|
|
ForEach(Array(tokens.enumerated()), id: \.offset) { _, token in
|
|
LyricsWordView(token: token, lookup: makeLookup(for: token)) { word in
|
|
selectedWord = word
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Lookup
|
|
|
|
private func makeLookup(for rawToken: String) -> LyricsWordLookup? {
|
|
let cleaned = rawToken.lowercased()
|
|
.trimmingCharacters(in: .punctuationCharacters)
|
|
.trimmingCharacters(in: .whitespaces)
|
|
guard !cleaned.isEmpty else { return nil }
|
|
|
|
if let cached = lookupCache[cleaned] { return cached }
|
|
guard let entry = dictionary.lookup(cleaned) else { return nil }
|
|
|
|
let displayWord = rawToken
|
|
.trimmingCharacters(in: .punctuationCharacters)
|
|
.trimmingCharacters(in: .whitespaces)
|
|
|
|
let tenseDisplay = entry.tenseId.flatMap { TenseInfo.find($0)?.english }
|
|
|
|
let lookup = LyricsWordLookup(
|
|
word: displayWord.isEmpty ? entry.word : displayWord,
|
|
baseForm: entry.baseForm,
|
|
english: entry.english,
|
|
partOfSpeech: entry.partOfSpeech,
|
|
tenseDisplay: tenseDisplay,
|
|
person: entry.person
|
|
)
|
|
lookupCache[cleaned] = lookup
|
|
return lookup
|
|
}
|
|
}
|
|
|
|
// MARK: - Word Lookup Model
|
|
|
|
private struct LyricsWordLookup: Identifiable, Hashable {
|
|
let word: String
|
|
let baseForm: String
|
|
let english: String
|
|
let partOfSpeech: String
|
|
let tenseDisplay: String?
|
|
let person: String?
|
|
|
|
var id: String { word }
|
|
}
|
|
|
|
// MARK: - Word View
|
|
|
|
private struct LyricsWordView: View {
|
|
let token: String
|
|
let lookup: LyricsWordLookup?
|
|
let onLookup: (LyricsWordLookup) -> Void
|
|
|
|
var body: some View {
|
|
Text(token + " ")
|
|
.font(.body.weight(.medium))
|
|
.foregroundStyle(.primary)
|
|
.underline(lookup != nil, color: .teal.opacity(0.35))
|
|
.contentShape(Rectangle())
|
|
.onLongPressGesture(minimumDuration: 0.35) {
|
|
if let lookup {
|
|
onLookup(lookup)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Detail Sheet
|
|
|
|
private struct LyricsWordDetailSheet: View {
|
|
let word: LyricsWordLookup
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
HStack {
|
|
Text(word.word)
|
|
.font(.title2.bold())
|
|
Spacer()
|
|
if !word.partOfSpeech.isEmpty {
|
|
Text(word.partOfSpeech)
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(.fill.tertiary, in: Capsule())
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
if !word.baseForm.isEmpty && word.baseForm.lowercased() != word.word.lowercased() {
|
|
detailRow(label: "Base form", value: word.baseForm, italic: true)
|
|
}
|
|
|
|
if !word.english.isEmpty {
|
|
detailRow(label: "English", value: word.english)
|
|
}
|
|
|
|
if let tenseDisplay = word.tenseDisplay {
|
|
let personSuffix = (word.person?.isEmpty == false) ? " · \(word.person!)" : ""
|
|
detailRow(label: "Tense", value: tenseDisplay + personSuffix)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func detailRow(label: String, value: String, italic: Bool = false) -> some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Text("\(label):")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 86, alignment: .leading)
|
|
Text(value)
|
|
.font(.subheadline.weight(.semibold))
|
|
.italic(italic)
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Flow Layout
|
|
|
|
private struct LyricsFlowLayout: Layout {
|
|
var spacing: CGFloat = 0
|
|
|
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
|
let rows = computeRows(proposal: proposal, subviews: subviews)
|
|
var height: CGFloat = 0
|
|
for row in rows { height += row.map { $0.height }.max() ?? 0 }
|
|
height += CGFloat(max(0, rows.count - 1)) * spacing
|
|
return CGSize(width: proposal.width ?? 0, height: height)
|
|
}
|
|
|
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
|
let rows = computeRows(proposal: proposal, subviews: subviews)
|
|
var y = bounds.minY
|
|
var idx = 0
|
|
for row in rows {
|
|
var x = bounds.minX
|
|
let rh = row.map { $0.height }.max() ?? 0
|
|
for size in row {
|
|
subviews[idx].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
|
x += size.width
|
|
idx += 1
|
|
}
|
|
y += rh + spacing
|
|
}
|
|
}
|
|
|
|
private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[CGSize]] {
|
|
let mw = proposal.width ?? .infinity
|
|
var rows: [[CGSize]] = [[]]
|
|
var cw: CGFloat = 0
|
|
for sv in subviews {
|
|
let s = sv.sizeThatFits(.unspecified)
|
|
if cw + s.width > mw && !rows[rows.count - 1].isEmpty {
|
|
rows.append([])
|
|
cw = 0
|
|
}
|
|
rows[rows.count - 1].append(s)
|
|
cw += s.width
|
|
}
|
|
return rows
|
|
}
|
|
}
|