Files
musicland/src/screens/Library/MusicDetails.js
T
2026-02-27 15:50:57 +01:00

927 lines
28 KiB
JavaScript

import { Feather } from '@expo/vector-icons'
import { Image as ExpoImage } from 'expo-image'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Linking,
Pressable,
Image as RNImage,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native'
import { SheetManager } from 'react-native-actions-sheet'
import { responsiveHeight, responsiveWidth } from 'react-native-responsive-dimensions'
import { useGlobal } from 'reactn'
import { background, icons } from '../../assets'
import PressableScale from '../../components/PressableScale'
import ProgressSlider from '../../components/player/ProgressSlider'
import { arrayRemove, arrayUnion, increment, projectsRef, usersRef } from '../../config/firebase'
import useDataFromRef from '../../hooks/useDataFromRef'
import usePlayer from '../../hooks/usePlayer'
import useTrackController from '../../hooks/useTrackController'
import Page from '../../layouts/Page'
import { Palette } from '../../styles'
import { FONT_FAMILY } from '../../styles/Fonts'
import Style, { gutters, size } from '../../styles/Style'
import { getArtistDisplayName } from '../../utils/artistName'
import { ensureAuthenticated } from '../../utils/authRedirect'
import { createMusicSharePayload, openShareSheet } from '../../utils/shareSheet'
import {
formatStructureLabel,
getPromptLabelForStructure,
getSegmentMeta,
normalizeStructureType,
} from '../../utils/songStructure'
// 20 secondes
const timeBeforeIncrement = 20000
const MusicDetails = ({ route }) => {
const { params } = route || {}
const action = params?.action
const autoPlayRequested = params?.autoPlay
const projectId = params?.projectId || null
const [fav, setFav] = useState(false)
const [currentUID] = useGlobal('currentUID')
const listenedMsRef = useRef(0)
const incrementDoneRef = useRef(false)
const timerRef = useRef(null)
const hasAutoPlayedRef = useRef(false)
const { isLooping, setLooping } = usePlayer() || {}
const { data: project } = useDataFromRef({
ref: projectId ? projectsRef.doc(projectId) : null,
simpleRef: true,
listener: true,
condition: !!projectId,
})
const { data: owner } = useDataFromRef({
ref: project?.userId ? usersRef.doc(project.userId) : null,
simpleRef: true,
listener: true,
condition: !!project?.userId,
})
useEffect(() => {
if (project && currentUID) {
const liked = Array.isArray(project?.likedBy) ? project.likedBy.includes(currentUID) : false
setFav(liked)
}
}, [project?.likedBy, currentUID])
const title = project?.title || 'Sans titre'
const artist = useMemo(() => {
const ownerName = getArtistDisplayName(owner, '')
if (ownerName) return ownerName
return getArtistDisplayName(project, 'MusicLand')
}, [owner, project])
const coverUrl = project?.coverUrl || null
const songUrl = project?.songUrl || null
const trackId = useMemo(() => {
if (projectId) return `project-${projectId}`
if (songUrl) return `song-${songUrl}`
return null
}, [projectId, songUrl])
const trackDescriptor = useMemo(() => {
if (!trackId || !songUrl) return null
return {
id: trackId,
uri: songUrl,
songUrl,
title,
artist,
artwork: coverUrl,
coverUrl,
metadata: { projectId, screen: 'MusicDetails' },
context: { projectId, screen: 'MusicDetails' },
}
}, [trackId, songUrl, title, artist, coverUrl, projectId])
const sharePayload = useMemo(() => {
return createMusicSharePayload({
projectId,
title,
artist,
})
}, [artist, projectId, title])
const handleShare = useCallback(() => {
if (!sharePayload) return
openShareSheet(sharePayload)
}, [sharePayload])
const handleReport = useCallback(() => {
if (!projectId) return
SheetManager.show('Report', {
payload: {
targetType: 'music',
projectId,
title,
ownerId: project?.userId || owner?.id || null,
},
})
}, [owner?.id, project?.userId, projectId, title])
const handleAddToPlaylist = useCallback(() => {
if (projectId) {
SheetManager.show('Playlist', { payload: { projectId } })
} else {
SheetManager.show('Playlist')
}
}, [projectId])
const handleDownload = useCallback(async () => {
if (!songUrl) return
try {
await Linking.openURL(songUrl)
} catch (error) {
console.log('MusicDetails download error', error?.message)
}
}, [songUrl])
const handleOpenOptions = useCallback(() => {
SheetManager.show('MusicOptions', {
payload: {
projectId,
title,
ownerId: project?.userId || owner?.id || null,
onReport: handleReport,
onAddToPlaylist: handleAddToPlaylist,
onDownload: handleDownload,
downloadDisabled: !songUrl,
onShare: sharePayload ? handleShare : null,
sharePayload,
shareDisabled: !sharePayload,
},
})
}, [
handleAddToPlaylist,
handleDownload,
handleReport,
handleShare,
owner?.id,
project?.userId,
projectId,
sharePayload,
songUrl,
title,
])
const {
isCurrent: isCurrentTrack,
isPlaying: isTrackPlaying,
positionMs,
durationMs,
ensureLoaded,
pause: pauseTrack,
resume: resumeTrack,
seekTo: seekTrackTo,
seekBy: seekTrackBy,
} = useTrackController(trackDescriptor)
const loopEnabled = isCurrentTrack && !!isLooping
// Karaoke aligned words (from timestamps)
const alignedWords = useMemo(() => {
try {
const idx = Number(project?.songIndex) || 0
const ts = project?.musicTimestamps?.[idx]
const arr = Array.isArray(ts?.alignedWords) ? ts.alignedWords : []
return arr.map((w) => ({
word: String(w?.word ?? ''),
startS: Number(w?.startS ?? 0),
endS: Number(w?.endS ?? 0),
}))
} catch (e) {
return []
}
}, [project?.musicTimestamps, project?.songIndex])
const estimatedDurationMs = useMemo(() => {
const toMs = (value) => {
const num = Number(value)
if (!Number.isFinite(num) || num <= 0) return 0
return num > 1000 ? Math.round(num) : Math.round(num * 1000)
}
const idx = Number(project?.songIndex)
const normalizedIdx = Number.isFinite(idx) && idx >= 0 ? idx : 0
const tsEntry = project?.musicTimestamps?.[normalizedIdx]
if (tsEntry && typeof tsEntry === 'object') {
const fromMetadata =
toMs(tsEntry.durationMs) ||
toMs(tsEntry.duration) ||
toMs(tsEntry.durationS) ||
toMs(tsEntry.durationSeconds) ||
toMs(tsEntry.audioDuration) ||
toMs(tsEntry.audioLength)
if (fromMetadata > 0) return fromMetadata
}
let maxEndS = 0
for (let i = 0; i < alignedWords.length; i++) {
const word = alignedWords[i]
const end = Number(word?.endS ?? word?.startS ?? 0)
if (Number.isFinite(end) && end > maxEndS) {
maxEndS = end
}
}
return maxEndS > 0 ? Math.round(maxEndS * 1000) : 0
}, [alignedWords, project?.musicTimestamps, project?.songIndex])
const sliderDurationMs = durationMs > 0 ? durationMs : estimatedDurationMs
// Reset counters when the track changes
useEffect(() => {
listenedMsRef.current = 0
incrementDoneRef.current = false
hasAutoPlayedRef.current = false
}, [trackId])
// Start/stop a timer to accumulate listened milliseconds while playing
useEffect(() => {
const clearTimer = () => {
if (timerRef.current) {
global.clearInterval(timerRef.current)
timerRef.current = null
}
}
if (isTrackPlaying && projectId) {
if (!timerRef.current) {
timerRef.current = global.setInterval(async () => {
try {
listenedMsRef.current += 500
if (
!incrementDoneRef.current &&
listenedMsRef.current >= timeBeforeIncrement &&
projectId
) {
incrementDoneRef.current = true
await projectsRef.doc(projectId).set({ views: increment(1) }, { merge: true })
}
} catch (e) {
// Silent fail for counter
}
}, 500)
}
} else {
clearTimer()
}
return clearTimer
}, [isTrackPlaying, projectId])
const togglePlay = useCallback(async () => {
if (!trackDescriptor) return
try {
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: positionMs, autoPlay: true })
return
}
if (isTrackPlaying) {
await pauseTrack()
} else {
await resumeTrack()
}
} catch (e) {
console.log('MusicDetails toggle error', e?.message)
}
}, [
trackDescriptor,
isCurrentTrack,
ensureLoaded,
positionMs,
isTrackPlaying,
pauseTrack,
resumeTrack,
])
const handleSliderSeekStart = useCallback(() => {
if (!trackDescriptor) return
}, [trackDescriptor])
const handleSliderSeek = useCallback(
async (targetMs) => {
const dur = sliderDurationMs || 0
if (!trackDescriptor || dur <= 0) return
const bounded = Math.max(0, Math.min(dur, Math.floor(targetMs)))
try {
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: bounded, autoPlay: false })
} else {
await seekTrackTo(bounded)
}
} catch (e) {
console.log('MusicDetails seek error', e?.message)
}
},
[trackDescriptor, sliderDurationMs, isCurrentTrack, ensureLoaded, seekTrackTo]
)
const handleToggleLoop = useCallback(async () => {
if (!isCurrentTrack) return
const next = !loopEnabled
if (typeof setLooping === 'function') {
setLooping(next)
}
if (next) {
try {
await seekTrackTo(0)
if (!isTrackPlaying) {
await resumeTrack()
}
} catch (e) {
console.log('MusicDetails loop toggle error', e?.message)
}
}
}, [isCurrentTrack, loopEnabled, setLooping, seekTrackTo, isTrackPlaying, resumeTrack])
useEffect(() => {
if (!autoPlayRequested || hasAutoPlayedRef.current) return
if (!trackDescriptor || !songUrl) return
const run = async () => {
try {
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: 0, autoPlay: true })
} else if (!isTrackPlaying) {
await resumeTrack()
}
hasAutoPlayedRef.current = true
} catch (e) {
console.log('MusicDetails autoplay error', e?.message)
}
}
run()
}, [
autoPlayRequested,
trackDescriptor,
songUrl,
isCurrentTrack,
ensureLoaded,
isTrackPlaying,
resumeTrack,
])
const handleSeekBySeconds = useCallback(
async (deltaSeconds) => {
if (!trackDescriptor) return
const deltaMs = Number(deltaSeconds || 0) * 1000
const target = Math.max(0, (positionMs || 0) + deltaMs)
try {
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: target, autoPlay: true })
} else {
await seekTrackBy(deltaMs)
}
} catch (e) {
console.log('MusicDetails seekBy error', e?.message)
}
},
[trackDescriptor, positionMs, isCurrentTrack, ensureLoaded, seekTrackBy]
)
const handleLyricsSeek = useCallback(
async (timestampS) => {
if (!trackDescriptor || typeof timestampS !== 'number' || Number.isNaN(timestampS)) return
const targetMs = Math.max(0, Number(timestampS) * 1000)
try {
if (!isCurrentTrack) {
await ensureLoaded({ startPositionMs: targetMs, autoPlay: true })
return
}
await seekTrackTo(targetMs)
if (!isTrackPlaying) {
await resumeTrack()
}
} catch (e) {
console.log('MusicDetails lyrics seek error', e?.message)
}
},
[trackDescriptor, isCurrentTrack, ensureLoaded, seekTrackTo, isTrackPlaying, resumeTrack]
)
const description = useMemo(() => {
// Build a readable text from lyrics with section labels
if (Array.isArray(project?.lyrics)) {
return project.lyrics
.filter((s) => {
const t = (s?.type || '').toLowerCase()
return ['couplet', 'refrain'].includes(t)
})
.map((s) => {
const body = (s?.lyrics || '').trim()
if (!body) return null
const t = (s?.type || '').toLowerCase()
const label = t === 'refrain' ? 'Refrain' : 'Couplet'
return `[${label}]\n${body}`
})
.filter(Boolean)
.join('\n\n')
}
const c = project?.lyrics?.couplet
const r = project?.lyrics?.refrain
const parts = [c ? `[Couplet]\n${c}` : null, r ? `[Refrain]\n${r}` : null].filter(Boolean)
return parts.length ? parts.join('\n\n') : ''
}, [project])
// Current time in seconds for highlighting
const currentTimeS = useMemo(() => Math.max(0, (positionMs || 0) / 1000), [positionMs])
// Preview lead: show words 0.5s earlier
const visibleTimeS = useMemo(() => Math.max(0, currentTimeS + 0.5), [currentTimeS])
// Helpers to group aligned words into sections and timed lines
const SECTION_TAG_REGEX = /^\s*\[([^\]]+)\]\s*$/i
const SECTION_TAG_LEADING_REGEX = /^\s*\[([^\]]+)\]\s*/i
const cleanText = (txt) =>
String(txt || '')
.replace(/\s+/g, ' ')
.trim()
const isSentenceEnd = (txt) => /[.!?]$/.test((txt || '').trim())
const stripSectionTag = (txt) => String(txt || '').replace(SECTION_TAG_REGEX, '')
const parseSectionTag = (txt) => {
const match = String(txt || '').match(SECTION_TAG_REGEX)
if (!match) return null
const label = match[1]?.trim() || ''
const lower = label.toLowerCase()
let type = 'section'
if (lower.includes('refrain') || lower.includes('chorus')) type = 'refrain'
else if (lower.includes('couplet') || lower.includes('verse')) type = 'couplet'
else if (
lower.includes('pré') ||
lower.includes('prechorus') ||
lower.includes('pre-chorus') ||
lower.includes('pre chorus') ||
lower.includes('pre-refrain')
)
type = 'pre_refrain_instrumental'
else if (lower.includes('pont') || lower.includes('bridge')) type = 'pont'
else if (lower.includes('intro')) type = 'intro'
const indexMatch = label.match(/(\d+)/)
const index = indexMatch ? Number(indexMatch[1]) : undefined
return { label, type, index }
}
const formatSectionLabel = (type, index, fallback) => {
if (fallback) return fallback
const normalized = normalizeStructureType(type)
const meta = getSegmentMeta(normalized)
if (meta) {
const label = formatStructureLabel(normalized, index)
if (label) return label
}
const prompt = getPromptLabelForStructure(normalized)
if (prompt) return prompt
return index > 1 ? `Section ${index}` : 'Section'
}
const groupAlignedWordsToLines = (words = [], { removeTags = true } = {}) => {
const out = []
let buf = []
let start = null
for (let i = 0; i < words.length; i++) {
const w = words[i] || {}
const original = String(w.word || '')
const textNoNewline = original.replace(/\n/g, ' ')
let working = textNoNewline
if (removeTags) {
while (true) {
const match = working.match(SECTION_TAG_LEADING_REGEX)
if (!match) break
working = working.slice(match[0].length)
}
working = stripSectionTag(working)
} else if (SECTION_TAG_REGEX.test(textNoNewline)) {
const joined = cleanText(textNoNewline)
if (joined)
out.push({
text: joined,
startS: start ?? Number(w.startS || 0),
endS: Number(w.endS || 0),
})
buf = []
start = null
continue
}
const cleaned = cleanText(removeTags ? working : textNoNewline)
if (!cleaned) continue
if (buf.length === 0) start = Number(w.startS || 0)
buf.push({
text: cleaned,
startS: Number(w.startS || 0),
endS: Number(w.endS || 0),
})
const next = words[i + 1] || null
const gapToNext = next ? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0)) : 0
const eolByNewline = /\n/.test(original)
const eolByPause = gapToNext >= 0.6 // logical break
const eolByPunct = isSentenceEnd(cleaned)
const isLast = i === words.length - 1
if (eolByNewline || eolByPause || eolByPunct || isLast) {
const joined = cleanText(buf.map((b) => b.text).join(' '))
if (joined)
out.push({
text: joined,
startS: start ?? Number(w.startS || 0),
endS: Number(w.endS || 0),
words: buf,
})
buf = []
start = null
}
}
return out
}
const sections = useMemo(() => {
const counts = {}
const grouped = []
let current = null
const startSection = (tagMeta) => {
const type = tagMeta?.type || 'section'
let index
if (tagMeta?.index !== undefined && Number.isFinite(tagMeta.index) && tagMeta.index > 0) {
index = tagMeta.index
counts[type] = Math.max(counts[type] || 0, index)
} else {
counts[type] = (counts[type] || 0) + 1
index = counts[type]
}
const label = formatSectionLabel(type, index, tagMeta?.label)
current = {
key: `${type}-${index}-${grouped.length}`,
type,
index,
label,
words: [],
}
}
const pushCurrent = () => {
if (!current || current.words.length === 0) {
current = null
return
}
const lines = groupAlignedWordsToLines(current.words, {
removeTags: true,
})
if (!lines.length) {
current = null
return
}
const startS = lines.reduce(
(acc, line) => Math.min(acc, Number.isFinite(line.startS) ? line.startS : acc),
Number.POSITIVE_INFINITY
)
const endS = lines.reduce((acc, line) => Math.max(acc, Number(line.endS || 0)), 0)
grouped.push({
key: current.key,
type: current.type,
index: current.index,
label: current.label,
startS: Number.isFinite(startS) ? startS : 0,
endS,
lines,
})
current = null
}
for (let i = 0; i < alignedWords.length; i++) {
const word = alignedWords[i] || {}
const raw = String(word.word || '')
let working = raw.replace(/\n/g, ' ')
let consumedTag = false
while (true) {
const match = working.match(SECTION_TAG_LEADING_REGEX)
if (!match) break
const tagMeta = parseSectionTag(`[${match[1]}]`)
if (tagMeta) {
pushCurrent()
startSection(tagMeta)
}
working = working.slice(match[0].length)
consumedTag = true
}
if (consumedTag && !working.trim()) {
continue
}
const cleanedWord = cleanText(working)
if (!cleanedWord) continue
if (!current) startSection(null)
current.words.push({
...word,
word: cleanedWord,
startS: Number(word.startS || 0),
endS: Number(word.endS || 0),
})
}
pushCurrent()
let lineIdx = 0
return grouped
.filter((s) => ['couplet', 'refrain'].includes(s.type))
.map((section) => ({
...section,
lines: section.lines.map((line) => ({
...line,
globalIdx: lineIdx++,
})),
}))
}, [alignedWords])
const flatLines = useMemo(() => sections.flatMap((section) => section.lines), [sections])
const currentLineIdx = useMemo(() => {
if (!flatLines || flatLines.length === 0) return -1
for (let i = 0; i < flatLines.length; i++) {
const L = flatLines[i]
if (visibleTimeS >= (L.startS || 0) && visibleTimeS <= (L.endS || 0)) return i
}
if (visibleTimeS > (flatLines[flatLines.length - 1]?.endS || 0)) return flatLines.length - 1
return -1
}, [flatLines, visibleTimeS])
// Auto-scroll lyrics to keep the current line visible
const lyricsRef = useRef(null)
const lineYRef = useRef({})
useEffect(() => {
const y = lineYRef.current?.[currentLineIdx]
if (lyricsRef.current && typeof y === 'number') {
try {
lyricsRef.current.scrollTo({ y: Math.max(0, y - 80), animated: true })
} catch (e) {}
}
}, [currentLineIdx])
return (
<Page
headerType="NAVIGATION"
title={action === 'userProfile' ? 'Mon profil' : 'Détail musique'}
backgroundImg={action === 'userProfile' ? background.profileBG : background.libraryBG2}
{...(action === 'userProfile' && {
containerStyle: {
backgroundColor: '#0000004D',
},
rightComponent: () => (
<Pressable onPress={() => SheetManager.show('DeleteAudio')}>
<RNImage source={icons.trash} />
</Pressable>
),
})}
>
<View style={{ flex: 1, paddingTop: 20, paddingBottom: gutters * 2 }}>
<View style={{ gap: 28 }}>
{coverUrl && (
<ExpoImage
source={{ uri: coverUrl }}
cachePolicy="memory-disk"
priority="high"
contentFit="cover"
transition={150}
style={styles.img}
/>
)}
<View style={{ gap: 12 }}>
<View
style={{
...Style.containerRow,
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<View>
<Text style={styles.title}>{title}</Text>
<Text style={styles.name}>{artist}</Text>
</View>
<View style={{ ...Style.containerRow, gap: 16 }}>
<PressableScale
onPress={handleToggleLoop}
disabled={!isCurrentTrack}
hitSlop={10}
style={{
...size({ size: 32 }),
alignItems: 'center',
justifyContent: 'center',
opacity: isCurrentTrack ? 1 : 0.4,
}}
contentStyle={{
alignItems: 'center',
justifyContent: 'center',
}}
>
<Feather
name="repeat"
size={24}
color={loopEnabled ? Palette.primary : Palette.white}
/>
</PressableScale>
<PressableScale
onPress={async () => {
if (!projectId) return
if (!ensureAuthenticated(currentUID)) return
const next = !fav
setFav(next)
try {
await projectsRef.doc(projectId).update({
likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID),
})
} catch (e) {
setFav(!next)
}
}}
>
<RNImage
source={fav ? icons.heart : icons.heartOutline}
style={{ ...size({ size: 24 }) }}
resizeMode="contain"
/>
</PressableScale>
<PressableScale onPress={handleOpenOptions}>
<RNImage
source={icons.more}
style={{ ...size({ size: 24 }) }}
resizeMode="contain"
/>
</PressableScale>
</View>
</View>
</View>
</View>
{songUrl && (
<View style={{ paddingTop: 22 }}>
<ProgressSlider
positionMs={positionMs}
durationMs={sliderDurationMs}
isPlaying={isTrackPlaying}
onSeekStart={handleSliderSeekStart}
onSeek={handleSliderSeek}
onPause={pauseTrack}
onPlay={resumeTrack}
disabled={!songUrl}
/>
<View style={{ ...Style.containerRow, gap: 24, alignSelf: 'center' }}>
{/* Previous (rewind 10s) */}
<Pressable onPress={() => handleSeekBySeconds(-10)}>
<RNImage
source={icons.forward}
style={{
...size({ size: 30 }),
}}
/>
</Pressable>
{/* Play / Pause */}
<Pressable
style={{
...size({ size: 40 }),
alignItems: 'center',
justifyContent: 'center',
}}
onPress={togglePlay}
>
<RNImage
resizeMode={'contain'}
source={isTrackPlaying ? icons.pause : icons.play}
style={size({ size: 34 })}
/>
</Pressable>
{/* Next (forward 10s) */}
<Pressable onPress={() => handleSeekBySeconds(10)}>
<RNImage
source={icons.forward}
style={{
...size({ size: 30 }),
transform: [{ rotate: '180deg' }],
}}
/>
</Pressable>
</View>
</View>
)}
<View style={{ flex: 1, marginTop: 20 }}>
{sections.length ? (
<ScrollView
ref={lyricsRef}
style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: 40 }}
>
{sections.map((section, sectionIdx) => (
<View
key={section.key}
style={[styles.sectionContainer, sectionIdx > 0 ? styles.sectionSpacing : null]}
>
<Text style={styles.sectionTitle}>
{section.label}
{'\n'}
</Text>
{section.lines.map((line) => (
<View
key={`line-${line.globalIdx}`}
onLayout={(e) => {
lineYRef.current[line.globalIdx] = e.nativeEvent.layout.y
}}
style={{
marginBottom: 8,
flexDirection: 'row',
flexWrap: 'wrap',
}}
>
{(line.words || []).map((w, j) => (
<Text
key={`w-${line.globalIdx}-${j}`}
style={
visibleTimeS >= (w.startS || 0)
? styles.karaokeWordActive
: styles.karaokeWord
}
onPress={() => handleLyricsSeek(w.startS)}
>
{w.text}
{j < (line.words?.length || 1) - 1 ? ' ' : ''}
</Text>
))}
</View>
))}
</View>
))}
</ScrollView>
) : description?.length > 0 ? (
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingBottom: 40 }}>
<Text
style={{
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
}}
>
{description}
</Text>
</ScrollView>
) : null}
</View>
</View>
</Page>
)
}
export default MusicDetails
const styles = StyleSheet.create({
img: {
width: 160,
height: 160,
borderRadius: 24,
alignSelf: 'center',
},
title: {
fontSize: 20,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueMedium,
width: responsiveWidth(70),
marginBottom: responsiveHeight(1),
},
name: {
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
},
karaokeContainer: {
fontSize: 16,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 24,
},
karaokeWord: {
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
karaokeWordActive: {
color: Palette.primary,
fontFamily: FONT_FAMILY.InterBold,
},
sectionContainer: {
marginBottom: 16,
},
sectionSpacing: {
marginTop: 12,
},
sectionTitle: {
color: Palette.transparentWhite,
fontFamily: FONT_FAMILY.InterSemiBold,
fontSize: 12,
letterSpacing: 0.4,
textTransform: 'uppercase',
marginBottom: 6,
},
})