From 96d0ff531824008d686ba7f2a319fa0b7af47cfb Mon Sep 17 00:00:00 2001 From: Shuming Liu Date: Sat, 12 Sep 2026 20:27:42 +0800 Subject: [PATCH] fixed a bug --- .../oraltrainer/sdk/OralTrainerController.kt | 418 ++++++++++++------ .../UserInterfaceState.xcuserstate | Bin 23698 -> 23710 bytes 2 files changed, 282 insertions(+), 136 deletions(-) diff --git a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt index 57d9fb5..2c1e427 100644 --- a/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt +++ b/android/oral-trainer-sdk/src/main/java/cn/learningpad/oraltrainer/sdk/OralTrainerController.kt @@ -11,7 +11,6 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.PlayerMessage import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import java.util.concurrent.CopyOnWriteArraySet import kotlin.math.max @@ -31,8 +30,10 @@ class OralTrainerController internal constructor( private var lastSentenceIndex: Int? = null private var stopAtSentenceEnd = false private var stopAtSentenceEndIndex: Int? = null - private var boundaryMessage: PlayerMessage? = null - private var boundaryGuardToken = 0 + private var baseItemIndex = 0 + private var clippedSentence: SentenceBoundary? = null + private var updatingPlaybackWindow = false + private var unclippedDurationMs = -1L var config: PlayerConfig = initialConfig private set @@ -47,10 +48,9 @@ class OralTrainerController internal constructor( if (released) { return } - enforceSentenceBoundary() notifySentenceIfChanged() notifySnapshot() - mainHandler.postDelayed(this, 250L) + mainHandler.postDelayed(this, IDLE_POLL_MS) } } @@ -66,24 +66,33 @@ class OralTrainerController internal constructor( exoPlayer.playWhenReady = initialConfig.autoPlay exoPlayer.addListener(object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { - notifySnapshot() - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - if (isPlaying) { - refreshSentenceBoundaryGuard() - } else { - cancelSentenceBoundaryGuard() + if (playbackState == Player.STATE_ENDED) { + onClippedSentenceEnded() + } + if (playbackState == Player.STATE_READY && unclippedDurationMs <= 0L) { + val duration = player.duration + if (duration != C.TIME_UNSET && clippedSentence == null) { + unclippedDurationMs = duration + } } notifySnapshot() } + override fun onIsPlayingChanged(isPlaying: Boolean) { + notifySnapshot() + } + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - lastSentenceIndex = null - cancelSentenceBoundaryGuard() + if (updatingPlaybackWindow) { + notifySnapshot() + return + } + if (clippedSentence == null) { + baseItemIndex = player.currentMediaItemIndex + lastSentenceIndex = null + } listeners.forEach { it.onMediaChanged(currentTrainingItem()) } notifySentenceIfChanged(force = true) - refreshSentenceBoundaryGuard() notifySnapshot() } @@ -93,7 +102,6 @@ class OralTrainerController internal constructor( reason: Int, ) { notifySentenceIfChanged(force = true) - refreshSentenceBoundaryGuard() notifySnapshot() } @@ -140,9 +148,16 @@ class OralTrainerController internal constructor( lastSentenceIndex = null stopAtSentenceEnd = false stopAtSentenceEndIndex = null - player.setMediaItems(items.map { it.toMedia3Item() }, safeIndex, max(0L, startPositionMs)) + baseItemIndex = safeIndex + clippedSentence = null + unclippedDurationMs = -1L + val startMs = max(0L, startPositionMs) + player.setMediaItems(items.map { it.toMedia3Item() }, safeIndex, startMs) player.prepare() player.playWhenReady = config.autoPlay + applyLoopMode() + lockSentenceAt(startMs) + syncPlaybackWindow(startMs) listeners.forEach { it.onMediaChanged(currentTrainingItem()) } notifySentenceIfChanged(force = true) notifySnapshot() @@ -166,25 +181,26 @@ class OralTrainerController internal constructor( return true } mediaItems[index] = updatedItem - if (index == player.currentMediaItemIndex) { + if (index == baseItemIndex) { listeners.forEach { it.onMediaChanged(updatedItem) } + lockSentenceAt(playbackPositionMs()) + syncPlaybackWindow() notifySentenceIfChanged(force = true) - refreshSentenceBoundaryGuard() notifySnapshot() } return true } fun play() { + syncPlaybackWindow() player.play() - refreshSentenceBoundaryGuard() } fun pause() { stopAtSentenceEnd = false stopAtSentenceEndIndex = null - cancelSentenceBoundaryGuard() player.pause() + applySentenceRepeatMode() } fun togglePlayPause() { @@ -198,19 +214,22 @@ class OralTrainerController internal constructor( fun stop() { stopAtSentenceEnd = false stopAtSentenceEndIndex = null - cancelSentenceBoundaryGuard() player.stop() } fun seekTo(positionMs: Long) { stopAtSentenceEnd = false stopAtSentenceEndIndex = null - player.seekTo(max(0L, positionMs)) - refreshSentenceBoundaryGuard() + val safePositionMs = max(0L, positionMs) + lockSentenceAt(safePositionMs) + syncPlaybackWindow(safePositionMs) + seekInCurrentWindow(safePositionMs) + notifySentenceIfChanged(force = true) + notifySnapshot() } fun seekRelative(deltaMs: Long) { - seekTo(player.currentPosition + deltaMs) + seekTo(playbackPositionMs() + deltaMs) } fun rewind(stepMs: Long = config.defaultSeekStepMs) { @@ -222,11 +241,11 @@ class OralTrainerController internal constructor( } fun previousItem() { - player.seekToPreviousMediaItem() + switchBaseItem(baseItemIndex - 1) } fun nextItem() { - player.seekToNextMediaItem() + switchBaseItem(baseItemIndex + 1) } fun setPlaybackSpeed(speed: Float) { @@ -240,20 +259,21 @@ class OralTrainerController internal constructor( fun setLoopMode(loopMode: LoopMode) { this.loopMode = loopMode - player.repeatMode = when (loopMode) { - LoopMode.OFF -> Player.REPEAT_MODE_OFF - LoopMode.ONE -> Player.REPEAT_MODE_ONE - LoopMode.ALL -> Player.REPEAT_MODE_ALL - } + applySentenceRepeatMode() } fun setSentenceMode(enabled: Boolean) { config = config.copy(sentenceMode = enabled) + syncPlaybackWindow() } fun setContinuousPlayback(enabled: Boolean) { config = config.copy(continuousPlayback = enabled) - refreshSentenceBoundaryGuard() + if (!enabled) { + lockSentenceAt(playbackPositionMs()) + } + syncPlaybackWindow() + notifySentenceIfChanged(force = true) } fun playCurrentSentenceAndStop() { @@ -265,22 +285,37 @@ class OralTrainerController internal constructor( fun playSentenceAndStop(startMs: Long, sentenceIndex: Int) { stopAtSentenceEnd = true stopAtSentenceEndIndex = sentenceIndex - player.seekTo(max(0L, startMs)) + lastSentenceIndex = sentenceIndex + val item = currentTrainingItem() + val sentence = item?.sentences?.getOrNull(sentenceIndex) + if (item != null && sentence != null) { + installSentenceClip(item, sentence, 0L) + } else { + player.seekTo(max(0L, startMs)) + } player.play() - refreshSentenceBoundaryGuard() + notifySentenceIfChanged(force = true) + notifySnapshot() } fun currentTrainingItem(): TrainingMediaItem? { - val index = player.currentMediaItemIndex - return mediaItems.getOrNull(index) + return mediaItems.getOrNull(baseItemIndex) } fun currentSentence(): SentenceBoundary? { val item = currentTrainingItem() ?: return null - val position = player.currentPosition - return item.sentences.lastOrNull { sentence -> - position >= sentence.startMs && position < sentence.endMs + if (item.sentences.isEmpty()) { + return null } + clippedSentence?.let { window -> + return item.sentences.getOrNull(window.index) + } + if (shouldBoundCurrentSentence()) { + lastSentenceIndex?.let { index -> + item.sentences.getOrNull(index)?.let { return it } + } + } + return sentenceAt(item, playbackPositionMs()) } fun seekToPreviousSentence(): Boolean { @@ -288,11 +323,16 @@ class OralTrainerController internal constructor( if (item.sentences.isEmpty()) { return false } - val position = player.currentPosition - val target = item.sentences - .asReversed() - .firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS } - ?: item.sentences.first() + val target = if (shouldBoundCurrentSentence()) { + val index = anchoredSentenceIndex(item) ?: 0 + item.sentences.getOrNull((index - 1).coerceAtLeast(0)) ?: item.sentences.first() + } else { + val position = playbackPositionMs() + item.sentences + .asReversed() + .firstOrNull { it.startMs < position - PREVIOUS_SENTENCE_TOLERANCE_MS } + ?: item.sentences.first() + } seekTo(target.startMs) return true } @@ -302,10 +342,15 @@ class OralTrainerController internal constructor( if (item.sentences.isEmpty()) { return false } - val position = player.currentPosition - val target = item.sentences.firstOrNull { - it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS - } ?: return false + val target = if (shouldBoundCurrentSentence()) { + val index = anchoredSentenceIndex(item) ?: return false + item.sentences.getOrNull(index + 1) ?: return false + } else { + val position = playbackPositionMs() + item.sentences.firstOrNull { + it.startMs > position + NEXT_SENTENCE_TOLERANCE_MS + } ?: return false + } seekTo(target.startMs) return true } @@ -362,12 +407,16 @@ class OralTrainerController internal constructor( fun snapshot(): PlaybackSnapshot { val item = currentTrainingItem() - val duration = player.duration.takeUnless { it == C.TIME_UNSET } ?: -1L + val duration = when { + unclippedDurationMs > 0L -> unclippedDurationMs + clippedSentence != null -> clippedSentence?.endMs ?: -1L + else -> player.duration.takeUnless { it == C.TIME_UNSET } ?: -1L + } return PlaybackSnapshot( mediaId = item?.id, - positionMs = max(0L, player.currentPosition), + positionMs = playbackPositionMs(), durationMs = duration, - bufferedPositionMs = max(0L, player.bufferedPosition), + bufferedPositionMs = playbackBufferedPositionMs(), isPlaying = player.isPlaying, playbackState = player.playbackState.toOralTrainerState(), playbackSpeed = player.playbackParameters.speed, @@ -380,7 +429,6 @@ class OralTrainerController internal constructor( return } released = true - cancelSentenceBoundaryGuard() mainHandler.removeCallbacks(ticker) listeners.clear() player.release() @@ -400,127 +448,224 @@ class OralTrainerController internal constructor( if (!force && index == lastSentenceIndex) { return } - val changed = index != lastSentenceIndex lastSentenceIndex = index listeners.forEach { it.onSentenceChanged(sentence) } - if (changed && !config.continuousPlayback && !stopAtSentenceEnd) { - refreshSentenceBoundaryGuard() - } } - private fun enforceSentenceBoundary() { - if (!config.sentenceMode || !player.isPlaying) { + private fun syncPlaybackWindow(positionMs: Long = playbackPositionMs()) { + if (released || updatingPlaybackWindow) { return } - val item = currentTrainingItem() ?: return - if (item.sentences.isEmpty()) { + val item = currentTrainingItem() + val target = sentenceForWindow(item) + if (target == null || item == null) { + restoreUnclipped(positionMs) return } - val position = player.currentPosition - if (stopAtSentenceEnd) { - val index = stopAtSentenceEndIndex ?: return - val sentence = item.sentences.getOrNull(index) ?: return - if (position >= sentence.endMs) { - applySentenceEndAction(sentence) - } + val current = clippedSentence + if (current != null && + current.index == target.index && + current.startMs == target.startMs && + current.endMs == target.endMs + ) { + applySentenceRepeatMode() return } - if (config.continuousPlayback) { - return - } - val anchorIndex = lastSentenceIndex ?: currentSentence()?.index ?: return - val anchor = item.sentences.getOrNull(anchorIndex) ?: return - if (position >= anchor.endMs) { - applySentenceEndAction(anchor) - } + val relativeMs = (positionMs - target.startMs).coerceAtLeast(0L) + installSentenceClip(item, target, relativeMs) } - private fun refreshSentenceBoundaryGuard() { - cancelSentenceBoundaryGuard() - if (released || !config.sentenceMode) { - return + private fun sentenceForWindow(item: TrainingMediaItem?): SentenceBoundary? { + if (item == null || !config.sentenceMode || item.sentences.isEmpty()) { + return null } - if (!player.isPlaying && !player.playWhenReady) { - return - } - val item = currentTrainingItem() ?: return - if (item.sentences.isEmpty()) { - return - } - - val sentence = when { - stopAtSentenceEnd -> item.sentences.getOrNull(stopAtSentenceEndIndex ?: return) + return when { + stopAtSentenceEnd -> item.sentences.getOrNull(stopAtSentenceEndIndex ?: return null) config.continuousPlayback -> null else -> { - val index = lastSentenceIndex ?: currentSentence()?.index - item.sentences.getOrNull(index ?: return) + val index = lastSentenceIndex ?: sentenceAt(item, playbackPositionMs())?.index + item.sentences.getOrNull(index ?: return null) } - } ?: return + } + } - val mediaItemIndex = player.currentMediaItemIndex - if (player.currentPosition >= sentence.endMs) { - applySentenceEndAction(sentence) + private fun installSentenceClip( + item: TrainingMediaItem, + sentence: SentenceBoundary, + relativePositionMs: Long, + ) { + if (released) { return } - - val token = ++boundaryGuardToken - boundaryMessage = player.createMessage { _, _ -> - if (token != boundaryGuardToken || released) { - return@createMessage - } - val active = currentTrainingItem() ?: return@createMessage - val latest = when { - stopAtSentenceEnd -> active.sentences.getOrNull(stopAtSentenceEndIndex ?: return@createMessage) - else -> active.sentences.getOrNull(sentence.index) - } ?: return@createMessage - if (player.currentMediaItemIndex != mediaItemIndex) { - return@createMessage - } - applySentenceEndAction(latest) - } - .setLooper(mainHandler.looper) - .setPosition(mediaItemIndex, sentence.endMs) - .setDeleteAfterDelivery(true) - .send() + rememberUnclippedDuration() + val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L) + val startPositionMs = relativePositionMs.coerceIn(0L, lengthMs) + val playWhenReady = player.playWhenReady + updatingPlaybackWindow = true + clippedSentence = sentence + lastSentenceIndex = sentence.index + player.setMediaItem(item.toMedia3Item(sentence.startMs, sentence.endMs), startPositionMs) + player.prepare() + player.playWhenReady = playWhenReady + applySentenceRepeatMode() + updatingPlaybackWindow = false } - private fun cancelSentenceBoundaryGuard() { - boundaryGuardToken++ - boundaryMessage?.let { message -> - runCatching { message.cancel() } + private fun restoreUnclipped(positionMs: Long) { + if (released) { + return } - boundaryMessage = null + if (clippedSentence == null && player.mediaItemCount == mediaItems.size) { + applyLoopMode() + return + } + val playWhenReady = player.playWhenReady + updatingPlaybackWindow = true + clippedSentence = null + player.setMediaItems( + mediaItems.map { it.toMedia3Item() }, + baseItemIndex.coerceIn(0, mediaItems.lastIndex), + max(0L, positionMs), + ) + player.prepare() + player.playWhenReady = playWhenReady + applyLoopMode() + updatingPlaybackWindow = false } - private fun applySentenceEndAction(sentence: SentenceBoundary) { + private fun seekInCurrentWindow(positionMs: Long) { + val sentence = clippedSentence + if (sentence == null) { + player.seekTo(max(0L, positionMs)) + return + } + val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L) + player.seekTo((positionMs - sentence.startMs).coerceIn(0L, lengthMs)) + } + + private fun onClippedSentenceEnded() { + val sentence = clippedSentence ?: return + player.seekTo(0L) + if (player.playbackState == Player.STATE_ENDED) { + player.prepare() + } if (stopAtSentenceEnd && stopAtSentenceEndIndex == sentence.index) { stopAtSentenceEnd = false stopAtSentenceEndIndex = null - cancelSentenceBoundaryGuard() player.pause() - player.seekTo(sentence.startMs) - lastSentenceIndex = sentence.index - notifySentenceIfChanged(force = true) - notifySnapshot() - return + applySentenceRepeatMode() + } else if (!config.continuousPlayback) { + player.play() } - if (config.continuousPlayback) { - return - } - // Loop the current sentence without leaking into the next one. - player.seekTo(sentence.startMs) lastSentenceIndex = sentence.index notifySentenceIfChanged(force = true) - refreshSentenceBoundaryGuard() notifySnapshot() } - private fun TrainingMediaItem.toMedia3Item(): MediaItem { + private fun switchBaseItem(targetIndex: Int) { + if (targetIndex !in mediaItems.indices) { + return + } + baseItemIndex = targetIndex + lastSentenceIndex = null + stopAtSentenceEnd = false + stopAtSentenceEndIndex = null + clippedSentence = null + unclippedDurationMs = -1L + val item = mediaItems[targetIndex] + val firstSentence = item.sentences.firstOrNull() + if (shouldBoundCurrentSentence() && firstSentence != null) { + lastSentenceIndex = firstSentence.index + installSentenceClip(item, firstSentence, 0L) + } else { + player.setMediaItem(item.toMedia3Item(), 0L) + player.prepare() + applyLoopMode() + } + listeners.forEach { it.onMediaChanged(item) } + notifySentenceIfChanged(force = true) + notifySnapshot() + } + + private fun playbackPositionMs(): Long { + val sentence = clippedSentence ?: return max(0L, player.currentPosition) + val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L) + return sentence.startMs + player.currentPosition.coerceIn(0L, lengthMs) + } + + private fun playbackBufferedPositionMs(): Long { + val sentence = clippedSentence ?: return max(0L, player.bufferedPosition) + val lengthMs = (sentence.endMs - sentence.startMs).coerceAtLeast(0L) + return sentence.startMs + player.bufferedPosition.coerceIn(0L, lengthMs) + } + + private fun shouldBoundCurrentSentence(): Boolean { + return config.sentenceMode && (!config.continuousPlayback || stopAtSentenceEnd) + } + + private fun lockSentenceAt(positionMs: Long) { + if (!shouldBoundCurrentSentence()) { + return + } + val item = currentTrainingItem() ?: return + lastSentenceIndex = sentenceAt(item, positionMs)?.index + } + + private fun anchoredSentenceIndex(item: TrainingMediaItem): Int? { + return lastSentenceIndex?.takeIf { it in item.sentences.indices } + ?: clippedSentence?.index + ?: sentenceAt(item, playbackPositionMs())?.index + } + + private fun sentenceAt(item: TrainingMediaItem, positionMs: Long): SentenceBoundary? { + return item.sentences.lastOrNull { positionMs >= it.startMs && positionMs < it.endMs } + ?: item.sentences.lastOrNull { it.startMs <= positionMs } + } + + private fun rememberUnclippedDuration() { + if (unclippedDurationMs > 0L || clippedSentence != null) { + return + } + val duration = player.duration + if (duration != C.TIME_UNSET) { + unclippedDurationMs = duration + } + } + + private fun applyLoopMode() { + player.repeatMode = when (loopMode) { + LoopMode.OFF -> Player.REPEAT_MODE_OFF + LoopMode.ONE -> Player.REPEAT_MODE_ONE + LoopMode.ALL -> Player.REPEAT_MODE_ALL + } + } + + private fun applySentenceRepeatMode() { + if (clippedSentence == null) { + applyLoopMode() + return + } + player.repeatMode = Player.REPEAT_MODE_OFF + } + + private fun TrainingMediaItem.toMedia3Item( + clippingStartPositionMs: Long = 0L, + clippingEndPositionMs: Long? = null, + ): MediaItem { val builder = MediaItem.Builder() .setMediaId(id) .setUri(uri) mimeType?.let(builder::setMimeType) customCacheKey?.let(builder::setCustomCacheKey) + if (clippingEndPositionMs != null) { + builder.setClippingConfiguration( + MediaItem.ClippingConfiguration.Builder() + .setStartPositionMs(max(0L, clippingStartPositionMs)) + .setEndPositionMs(max(0L, clippingEndPositionMs)) + .setStartsAtKeyFrame(false) + .build() + ) + } subtitleUri?.let { uri -> builder.setSubtitleConfigurations( listOf( @@ -552,5 +697,6 @@ class OralTrainerController internal constructor( private companion object { const val PREVIOUS_SENTENCE_TOLERANCE_MS = 250L const val NEXT_SENTENCE_TOLERANCE_MS = 150L + const val IDLE_POLL_MS = 250L } } diff --git a/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OralTrainer.xcodeproj/project.xcworkspace/xcuserdata/liushuming.xcuserdatad/UserInterfaceState.xcuserstate index 2e0f7a39d5cec238716896caf97f46330056b779..43073e8a88688b75878aecbbc4de38016339e7d0 100644 GIT binary patch delta 1379 zcmX|BYfKbZ6rOu`1|KMo@=$`z2<)zC0ReaK?2@7^Gk4~$>8u7^FuqY%Kn1E$wN}DR zTD8_6ZJpY*s3=PX6{@Y%rmwQV%ghN9NAjV7(8jZ`D8VzjBIcfop-IcL6{?|k=s z=f~ZB611HJA9sNSC!7Ye;B=S+^Pmg5VG*2deE~EuaS^+i4Y8})YPN=5XC>O)6S(lz zsC&a!Aa)^${NB&9y5r_V&j3S=opCaDa32hV2Q)uqoO-K1QIDlxoBol0jOODMIn4eX z!TfDXf_cpqZ=TIfw(6!71KSZ===EfpEk*VWm zXJ&ELK_+r8F%y{>T0zjuB!{60J^E3-I}8h%e!WL8)*Z{L8dkkr7piZ1F;rjORI_+p zZGFgJUlrOMYK&joVG_Z?6;fW0wLvJ2<2<$#dbfFDrW|cIfWrXJXJh*E7K3bae_s0d z&H>v+Cf3$RXE~pj4mz#Dycal7X106gPA+51BV%~jpx$kO(lBU+h5BcDk7>-F>|mF$ zE9gSlrR*|xxd9d!AZUQ{4rpgrva9sN2Ba@NIlIe}?bn zFYx{R75+N^C;voXgmFT$kSe4Jf?yY%LY6Q?@Cw+9D$8Vx6jX~gqE}Hf($O}w1MNh+ zP%G*}$IvI}Q}j7HgL=_9^esA%Zi&x{vZ#rR#5%D-+$?Sr-?vs*d}(WGmySxOrE^lB z^po_vbW6G|-Ie~9hNXuxCy$k*kTE2N#FIoaiKGxG$su`U7D1$l6cdRkgpl>*0J%smlOM?qGDz-_ zA##uWL)Jg`Gk%+&^T+s;{Jh_(xiydG)8=XA+EQ(ewn^KfHEX)|nzmirslBItpzYP# vw05mSJEV1KecE4v)Bp|y0u_N3fmMO+fwuy0k31y|grhm?!D#+J26q1kUBUsG^fnoqip|p-~G&fpYQX0 zpWS;LbRP#t4}fvgpcAISbm)R^m@dHNaB`Ziq%<>} zU1W76DFD1{_7(UVU^1K{j1NHs9W2E9L&8Ov%1YUO=3KPAr8VGdZ4A5=SXJNHwsg&k zKLc7VO#53}J6n8Q9`2ic%G0kJ3# z*U3;Z_o5WXKv-BWbU`mHtoBz_Rr$0K^a?Bti@2tkYlcut%)r#r#(hHMF{#5Tjm%@U zJIdoFWc!eX%&&8_Q8_{241%(vy2>r2E0K)va2HaVY6BT&l9plhsfke9w&_H@&H4lv zf-+gv+_Um?%d&H^SIPEx^0n+Tucl?Y%5--D_R5|zS6?PF(?^jp&V3%RD=;hJ~R)Nqe@hbYEckb=rX#7hS4MR*b(ipI}#kpj!Z|fW2QB> zVyaj?M{bcjv%4%#fxwwUV&HQb@)}h3BQSV;U3(N2k{Yn5`Tlk_zM0W|A>e1 zb$k=w!o&Cxeyl@X)T8tmJx-6`ppVlN^$B{a?$yinfWBUTOYhY!{k(pK#E^KBOfrav zl#p3OBZSN*6{MOhAa$gHEFqm_Cpk)PklW-A`HlQZA(dz}jiqsP5>27gXgbZLE}Bmj zT1bm%fSUAEdWN2*SLqPFPH)m%^jA9nF1<$|(1$+g8|548n`BHkvJ8)*8ncc0M!m7j zXf<9mRvT-LPNU1%YHT-l7~RHBW0&!^(QBMGe)ik_UVoYYd4Hw9#^34R=zq<8rOp91 F{{s{6sS^MI