From 98efb55611d754b05af4dd77900a86c25b1f8e72 Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 26 Feb 2026 14:47:43 +0100 Subject: [PATCH] feat: time offset playback --- src/components/KaraokeLyrics.js | 26 ++++++++++++------- src/screens/Playback/RecordPlayback.js | 2 ++ src/screens/Playback/RecordPlayback.web.js | 2 ++ .../Playbacks/components/PlaybackItem.web.js | 7 ++++- .../components/PlaybackLyricsCard.js | 8 +++++- src/screens/cover/PouchReady.js | 1 + src/screens/cover/SongDownload.js | 24 ++++++++++++++++- src/screens/cover/ValidateCover.js | 1 + 8 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/components/KaraokeLyrics.js b/src/components/KaraokeLyrics.js index 034fbd1..c4ee28b 100644 --- a/src/components/KaraokeLyrics.js +++ b/src/components/KaraokeLyrics.js @@ -94,6 +94,8 @@ export function groupAlignedWordsToLineItems( const original = String(w.word || '') const textNoNewline = original.replace(/\n/g, ' ') const textNoTag = removeTags ? textNoNewline.replace(/^\s*\[[^\]]+\]\s*/i, '') : textNoNewline + const next = alignedWords[i + 1] || null + const gapToNext = next ? Math.max(0, Number(next.startS || 0) - Number(w.endS || 0)) : 0 if (buf.length === 0) start = Number(w.startS || 0) if (isSectionTag(textNoNewline)) { @@ -132,10 +134,11 @@ export function groupAlignedWordsToLineItems( buf.push(wordItem) const eolByNewline = /\n/.test(original) + const eolByPause = gapToNext >= 0.6 // threshold for a logical break const eolByPunct = isSentenceEnd(textNoTag) const isLast = i === alignedWords.length - 1 - if (buf.length && (eolByNewline || eolByPunct || isLast)) { + if (buf.length && (eolByNewline || eolByPause || eolByPunct || isLast)) { pushLine(buf, Number(w.endS || 0)) buf = [] start = null @@ -151,6 +154,7 @@ const alphaWhite = (alpha) => `rgba(255,255,255,${alpha})` export default function KaraokeLyrics({ alignedWords = [], currentTimeS = 0, + timeOffsetS = 0, showContext = true, removeTags = true, mode = 'focus', @@ -170,17 +174,21 @@ export default function KaraokeLyrics({ ) const activeLines = mode === 'teleprompter' ? teleprompterData : lines + const effectiveTimeS = useMemo( + () => Math.max(0, Number(currentTimeS || 0) + Number(timeOffsetS || 0)), + [currentTimeS, timeOffsetS] + ) const currentLineIdx = useMemo(() => { if (!activeLines || activeLines.length === 0) return -1 + if (effectiveTimeS <= (activeLines[0]?.startS || 0)) return 0 for (let i = 0; i < activeLines.length; i++) { const L = activeLines[i] - if (currentTimeS >= (L.startS || 0) && currentTimeS <= (L.endS || 0)) return i + if (effectiveTimeS < (L.startS || 0)) return Math.max(0, i - 1) + if (effectiveTimeS >= (L.startS || 0) && effectiveTimeS <= (L.endS || 0)) return i } - if (currentTimeS > (activeLines[activeLines.length - 1]?.endS || 0)) - return activeLines.length - 1 - return -1 - }, [activeLines, currentTimeS]) + return activeLines.length - 1 + }, [activeLines, effectiveTimeS]) if (!activeLines.length) return null @@ -208,8 +216,8 @@ export default function KaraokeLyrics({ }} > {activeLines.map((line, idx) => { - const isUpcoming = currentTimeS < (line.startS || 0) - const isPast = currentTimeS > (line.endS || 0) + const isUpcoming = effectiveTimeS < (line.startS || 0) + const isPast = effectiveTimeS > (line.endS || 0) const lineKey = `${line.startS}-${idx}` if (isUpcoming) { @@ -258,7 +266,7 @@ export default function KaraokeLyrics({ > {line.words.map((word, wordIndex) => { const duration = Math.max(0.001, (word.endS || 0) - (word.startS || 0)) - const progress = clamp01((currentTimeS - (word.startS || 0)) / duration) + const progress = clamp01((effectiveTimeS - (word.startS || 0)) / duration) const alpha = inactiveAlpha + (activeAlpha - inactiveAlpha) * progress const text = word.text return ( diff --git a/src/screens/Playback/RecordPlayback.js b/src/screens/Playback/RecordPlayback.js index 2fa696a..08ff9e5 100644 --- a/src/screens/Playback/RecordPlayback.js +++ b/src/screens/Playback/RecordPlayback.js @@ -24,6 +24,7 @@ const TIME_BEFORE_INCREMENT_MS = 20000 // 20s const COUNTDOWN_SECONDS = 10 const CAMERA_STARTUP_DELAY_MS = 250 const MAX_SYNC_OFFSET_MS = 2000 +const KARAOKE_LEAD_S = 0.18 const LOG_PREFIX = '[RecordPlayback]' const KEEP_AWAKE_TAG = 'record-playback' const log = @@ -1099,6 +1100,7 @@ const RecordPlayback = ({ route }) => { { const n = Number(v ?? 0) @@ -1008,6 +1009,7 @@ const RecordPlayback = ({ route }) => { element instead of expo-video // - No Platform checks @@ -473,7 +474,11 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid, onBackgroundSyn {alignedWords?.length > 0 ? ( - + ) : ( {descriptionText} )} diff --git a/src/screens/Playbacks/components/PlaybackLyricsCard.js b/src/screens/Playbacks/components/PlaybackLyricsCard.js index 08ba341..ca7f377 100644 --- a/src/screens/Playbacks/components/PlaybackLyricsCard.js +++ b/src/screens/Playbacks/components/PlaybackLyricsCard.js @@ -5,12 +5,18 @@ import KaraokeLyrics from '../../../components/KaraokeLyrics' import { Palette } from '../../../styles' import { FONT_FAMILY } from '../../../styles/Fonts' +const PLAYBACK_LYRICS_LEAD_S = 0.18 + const PlaybackLyricsCard = ({ alignedWords, currentTimeS, fallbackTitle }) => { return ( {alignedWords?.length > 0 ? ( - + ) : ( {fallbackTitle || 'Description chanson'} )} diff --git a/src/screens/cover/PouchReady.js b/src/screens/cover/PouchReady.js index 1ad5070..051ac3f 100644 --- a/src/screens/cover/PouchReady.js +++ b/src/screens/cover/PouchReady.js @@ -266,6 +266,7 @@ const PouchReady = () => { project: selectedProject, selectedOption, coverOptions, + backRoute: Routes.PouchReady, }) }, [coverOptions, navigate, selectedOption, selectedProject]) diff --git a/src/screens/cover/SongDownload.js b/src/screens/cover/SongDownload.js index 98a99d5..33d3c7b 100644 --- a/src/screens/cover/SongDownload.js +++ b/src/screens/cover/SongDownload.js @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Image as ExpoImage } from 'expo-image' import { + BackHandler, Linking, Platform, Pressable, @@ -10,6 +11,7 @@ import { View, Alert, } from 'react-native' +import { useFocusEffect } from '@react-navigation/native' import useMinuit from 'react-native-minuit/src/hooks/useMinuit.js' import * as FileSystem from 'expo-file-system' import * as Sharing from 'expo-sharing' @@ -42,6 +44,7 @@ const SongDownload = ({ route }) => { coverOptions: routeCoverOptions, skipAdventureGate = false, promptPurchaseConfirm = false, + backRoute = null, } = route?.params || {} const { selectedProject, updateProjectData, hasActiveSubscription, hasPurchased } = useUser() const { createSongDownloadCheckout } = useStripe() @@ -111,6 +114,25 @@ const SongDownload = ({ route }) => { const showSubscriptionCta = !hasActiveSubscription const shouldShowDownloadPage = adventureChoice === 'stop' + const handleBackPress = useCallback(() => { + if (backRoute) { + navigate(backRoute) + return true + } + goBack() + return true + }, [backRoute]) + + useFocusEffect( + useCallback(() => { + if (Platform.OS !== 'android' || !backRoute) { + return undefined + } + const subscription = BackHandler.addEventListener('hardwareBackPress', handleBackPress) + return () => subscription.remove() + }, [backRoute, handleBackPress]) + ) + useEffect(() => { if (adventureGateTriggeredRef.current) { return @@ -509,7 +531,7 @@ const SongDownload = ({ route }) => { return ( - + {shouldShowDownloadPage ? ( { project: selectedProject, selectedOption, coverOptions, + backRoute: Routes.ValidateCover, }) }