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
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,11 @@ interface PikoUserPreferences {
suspend fun setPlayerBoostSpeed(speed: Float)

/** 信息流 30 秒片段播完后自动顺延续播当前视频后续内容,默认开启。跟着设置同步。 */
/** Device-local clip volume and mute state, stored together. */
var clipAudioSettings: String
get() = "1.0,false"
set(value) {}

val clipAutoExtendFlow: Flow<Boolean>
suspend fun setClipAutoExtend(enabled: Boolean)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2151,6 +2151,34 @@ open class PikoDriveRepository(
emit(FolderUsage(files, bytes, FolderUsage.Progress.COMPLETE))
}.flowOn(Dispatchers.Default)

private val previewDurationFetches = Semaphore(2)
private val previewDurations = LinkedHashMap<String, Long?>()

/** Only visible video cards request details; successful metadata is also stored by fetchDetail. */
suspend fun previewDurationSeconds(file: FileStat): Long? {
if (!file.isPlayableVideo() || file.isFolder) return null
contentMetadataOf(file)?.durationMillis?.takeIf { it > 0 }?.let { return it / 1000 }
file.params["duration"]?.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 1 }?.let { return it.toLong() }
if (LocalLocation.isLocal(file.id) || file.isVaulted || file.id.startsWith("import-")) return null
val owner = clientManager.currentClient.value ?: return null
val key = "${owner.account}:${file.id}"
return previewDurationFetches.withPermit {
synchronized(previewDurations) {
if (previewDurations.containsKey(key)) return@withPermit previewDurations[key]
}
if (clientManager.currentClient.value !== owner) return@withPermit null
val detail = runSuspendCatching { fetchDetail(file.id, owner) }.getOrNull() ?: return@withPermit null
val seconds = detail.params["duration"]?.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 1 }?.toLong()
?: detail.medias.firstOrNull { it.isOrigin }?.video?.duration?.takeIf { it > 0 }
?: detail.medias.firstNotNullOfOrNull { it.video?.duration?.takeIf { d -> d > 0 } }
synchronized(previewDurations) {
previewDurations[key] = seconds
if (previewDurations.size > 512) previewDurations.remove(previewDurations.keys.first())
}
seconds
}
}

suspend fun getFileDetail(fileId: String): Result<FileDetail> = withContext(Dispatchers.Default) {
runSuspendCatching { fetchDetail(fileId, client) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ class StoredPikoPreferences(
this.language.value = language
}

override var clipAudioSettings: String
get() = settings.get("player.clipAudio", "1.0,false")
set(value) { settings.set("player.clipAudio", value) }

private val clipAutoExtend = MutableStateFlow(settings.get("player.clipAutoExtend", "true").toBoolean())
override val clipAutoExtendFlow: Flow<Boolean> = clipAutoExtend.asStateFlow()
override suspend fun setClipAutoExtend(enabled: Boolean) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class ClipFeedSession(
private val media: PikoMediaRepository,
private val cacheStore: PikoCacheStore?,
private val scope: CoroutineScope,
private val audioPreferences: dev.piko.data.auth.PikoUserPreferences? = null,
) {
var root by mutableStateOf<PikoPathBreadcrumb?>(null)
private set
Expand Down Expand Up @@ -134,7 +135,28 @@ class ClipFeedSession(
private set

/** 静音。放在会话上而不是页面上:打开完整播放器或换到独立窗口时页面会重建,静音要跟着走。 */
var muted by mutableStateOf(false)
private val savedAudio = audioPreferences?.clipAudioSettings?.split(",").orEmpty()
var muted by mutableStateOf(savedAudio.getOrNull(1) == "true")
private set

var volume by mutableStateOf(savedAudio.firstOrNull()?.toFloatOrNull()?.takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: 1f)
private set

private fun saveAudio() {
audioPreferences?.clipAudioSettings = "$volume,$muted"
}

fun toggleMute() {
if (muted && volume == 0f) volume = 1f
muted = !muted
saveAudio()
}

fun updateVolume(value: Float) {
volume = value.coerceIn(0f, 1f)
muted = volume == 0f
saveAudio()
}

/** 控件连同顶栏全部藏起。与 [muted] 同理放在会话上,关掉信息流时清零,不存盘。 */
var immersive by mutableStateOf(false)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package dev.piko.shared.data

import dev.piko.shared.smoke.FakePikPakServer
import io.github.nihildigit.pikpak.FileStat
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

class PreviewDurationTest {
@Test fun missingListingDurationUsesDetailAndCachesResult() = runBlocking {
val server = FakePikPakServer()
val node = server.addVideo("sample.m2ts", durationSeconds = 2510.0)
val repo = PikoDriveRepository(server.provider())
val file = FileStat(id = node.id, name = node.name)
assertEquals(2510L, repo.previewDurationSeconds(file))
val calls = server.calls.size
assertEquals(2510L, repo.previewDurationSeconds(file))
assertEquals(calls, server.calls.size)
}
@Test fun existingDurationAvoidsNetworkAndMissingDetailIsNotInvented() = runBlocking {
val server = FakePikPakServer()
val repo = PikoDriveRepository(server.provider())
assertEquals(90L, repo.previewDurationSeconds(FileStat(id = "known", name = "known.mp4", params = mapOf("duration" to "90"))))
assertEquals(0, server.calls.size)
val node = server.addFile("unknown.mp4")
assertNull(repo.previewDurationSeconds(FileStat(id = node.id, name = node.name)))
val calls = server.calls.size
assertNull(repo.previewDurationSeconds(FileStat(id = node.id, name = node.name)))
assertEquals(calls, server.calls.size)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,48 @@ import kotlin.test.assertTrue
import kotlinx.serialization.json.Json

class ClipFeedSessionTest {
@Test fun audioSettingsSurviveSessionRecreation() = runSession { server, drive, _, cache ->
val prefs = object : dev.piko.data.auth.PikoUserPreferences by MemoryPreferences() {
override var clipAudioSettings = "1.0,false"
}
val testScope = CoroutineScope(SupervisorJob())
try {
fun create() = ClipFeedSession(server.provider(), drive, PikoMediaRepository(server.provider()), cache, testScope, prefs)
val first = create()
first.updateVolume(0.27f)
first.toggleMute()
first.close()
val second = create()
assertEquals(0.27f, second.volume)
assertTrue(second.muted)
second.toggleMute()
second.close()
val third = create()
assertEquals(0.27f, third.volume)
assertFalse(third.muted)
third.close()
} finally { testScope.cancel() }
}

@Test fun volumeAndMutePreserveLevelAndRecoverFromZero() = runSession { _, _, session, _ ->
session.updateVolume(0.35f)
session.toggleMute()
assertTrue(session.muted)
assertEquals(0.35f, session.volume)
session.toggleMute()
assertFalse(session.muted)
assertEquals(0.35f, session.volume)
session.updateVolume(0f)
assertTrue(session.muted)
session.toggleMute()
assertFalse(session.muted)
assertEquals(1f, session.volume)
session.updateVolume(2f)
assertEquals(1f, session.volume)
session.updateVolume(-1f)
assertEquals(0f, session.volume)
}

private val path = listOf(PikoDriveRepository.ROOT_BREADCRUMB)

private fun runSession(block: suspend (FakePikPakServer, PikoDriveRepository, ClipFeedSession, MemoryCacheStore) -> Unit) {
Expand Down
2 changes: 1 addition & 1 deletion ui/src/commonMain/kotlin/dev/piko/ui/PikoServices.kt
Original file line number Diff line number Diff line change
Expand Up @@ -443,7 +443,7 @@ class PikoServices(

// 主线程且与进程同寿:打开完整播放器时随机片段页可能被销毁,队列要留着回来接着看
val clipFeedSession: ClipFeedSession by lazy {
ClipFeedSession(clientManager, driveRepository, mediaRepository, cacheStore, CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate))
ClipFeedSession(clientManager, driveRepository, mediaRepository, cacheStore, CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate), preferences)
}

init {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ fun FileListItem(
supporting = {
Column {
FileItemInformation(item, showMetadata = tags.isEmpty() || !file.isFolder)
FileAddedTime(file)
if (!locationLabel.isNullOrEmpty()) {
// 路径从头截断:离命中项最近的几级目录最有辨识度
Text(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,21 @@ internal fun fileItemLongPress(action: (() -> Unit)?): (() -> Unit)? {
}
}
}

@Composable
internal fun FileAddedTime(file: FileStat) {
val time = remember(file.createdTime) {
runCatching {
java.time.OffsetDateTime.parse(file.createdTime)
.atZoneSameInstant(java.time.ZoneId.systemDefault())
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
}.getOrNull()
} ?: return
Text(
text = tr("加入时间:", "Added: ", "追加日時:", "추가 시간: ", "加入時間:") + time,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
35 changes: 27 additions & 8 deletions ui/src/commonMain/kotlin/dev/piko/ui/screens/clips/ClipChrome.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Surface
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.TextButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.TooltipAnchorPosition
Expand Down Expand Up @@ -139,6 +142,8 @@ internal fun ClipFeedTopBar(
onHideChrome: (() -> Unit)? = null,
landscapeLocked: Boolean? = null,
onToggleLandscape: () -> Unit = {},
volume: Float = 1f,
onVolumeChange: (Float) -> Unit = {},
) {
val buttonSize = if (compact) 40.dp else 48.dp
// 在主窗口里全屏或停在右侧一栏时,这一行贴着窗口右上角,窗口按钮接在关闭后面
Expand Down Expand Up @@ -187,14 +192,28 @@ internal fun ClipFeedTopBar(
shortcut = "A",
)
}
ChromeIconButton(
icon = if (muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
label = if (muted) tr("取消静音", "Unmute", "ミュートを解除", "음소거 해제", "取消靜音") else tr("静音", "Mute", "ミュート", "음소거", "靜音"),
onClick = onToggleMute,
size = buttonSize,
tooltip = true,
shortcut = "M",
)
Box {
var showVolume by remember { mutableStateOf(false) }
ChromeIconButton(
icon = if (muted || volume == 0f) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
label = tr("音量", "Volume", "音量", "음량", "音量"),
onClick = { showVolume = !showVolume },
size = buttonSize,
tooltip = true,
shortcut = "M",
)
DropdownMenu(expanded = showVolume, onDismissRequest = { showVolume = false }) {
Column(Modifier.widthIn(min = 200.dp, max = 240.dp).padding(horizontal = 16.dp)) {
Text(tr("音量", "Volume", "音量", "음량", "音量") + " ${(volume * 100).toInt()}%")
Slider(value = volume, onValueChange = onVolumeChange, valueRange = 0f..1f)
TextButton(onClick = {
if (muted && volume == 0f) onVolumeChange(1f) else onToggleMute()
}) {
Text(if (muted) tr("取消静音", "Unmute", "ミュートを解除", "음소거 해제", "取消靜音") else tr("静音", "Mute", "ミュート", "음소거", "靜音"))
}
}
}
}
if (onDock != null) {
// 置顶与播放窗口同一个按钮:一边刷一边浏览网盘时,独立窗口一点主窗口就被盖到后面
val window = LocalFramelessWindow.current
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,9 @@ fun ClipFeedScreen(
ClipFeedTopBar(
title = session.scopeLabel,
muted = session.muted,
onToggleMute = { session.muted = !session.muted },
volume = session.volume,
onVolumeChange = session::updateVolume,
onToggleMute = { session.toggleMute() },
onClose = { if (inLandscape) session.landscape = false else onBackClick() },
compact = compact,
autoAdvance = session.autoAdvance,
Expand Down Expand Up @@ -384,6 +386,8 @@ private fun ClipPager(
val chromeAlpha by animateFloatAsState(if (scrubbing) 0f else 1f, motion.defaultEffectsSpec())
val muted = session.muted
val latestMuted by rememberUpdatedState(muted)
val volume = session.volume
val latestVolume by rememberUpdatedState(volume)
// 开播前先缓存好的段数;到 WARM_UP_CLIPS 或超时才开播
var warmed by remember { mutableIntStateOf(0) }
var warmUpDone by remember { mutableStateOf(false) }
Expand Down Expand Up @@ -508,7 +512,7 @@ private fun ClipPager(
playWhenReady = false,
)
// 换文件后音量是否沿用看后端,静音时每次装完都再设一遍
if (latestMuted) players[slot].setVolume(0f)
players[slot].setVolume(if (latestMuted) 0f else latestVolume)
openMs = openStarted.elapsedNow().inWholeMilliseconds
} catch (e: CancellationException) {
throw e
Expand Down Expand Up @@ -547,7 +551,7 @@ private fun ClipPager(
if (!pointerSource.isTouchLike) runCatching { focusRequester.requestFocus() }
}
LaunchedEffect(pagerState) { snapshotFlow { latestSettled }.collect(session::moveTo) }
LaunchedEffect(muted) { players.forEach { it.setVolume(if (muted) 0f else 1f) } }
LaunchedEffect(muted, volume) { players.forEach { it.setVolume(if (muted) 0f else volume) } }
// 手指还按着就翻走了,松手的事件落不到这一页
LaunchedEffect(settled) { stopBoost() }

Expand Down Expand Up @@ -989,7 +993,7 @@ private fun ClipPager(
Key.Spacebar, Key.MediaPlayPause -> if (!repeat) togglePause()
Key.MediaPlay -> if (!repeat && paused) togglePause()
Key.MediaPause, Key.MediaStop -> if (!repeat && !paused) togglePause()
Key.M -> if (!repeat) session.muted = !session.muted
Key.M -> if (!repeat) session.toggleMute()
Key.A -> if (!repeat) onToggleAutoAdvance()
else -> return@onPreviewKeyEvent false
}
Expand Down
15 changes: 14 additions & 1 deletion ui/src/commonMain/kotlin/dev/piko/ui/screens/drive/PosterCard.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.produceState
import dev.piko.ui.LocalPikoServices
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -118,6 +120,15 @@ internal fun PosterCard(
/** 文件夹里直接放着归档条目,见 itemMarks。 */
folderHasVault: Boolean = false,
) {
val repository = LocalPikoServices.current.driveRepository
val durationSeconds by produceState<Long?>(initialValue = null, file, repository) {
value = repository.previewDurationSeconds(file)
}
val durationLabel = durationSeconds?.takeIf { it > 0 }?.let { seconds ->
val minutes = (seconds % 3600 / 60).toString().padStart(2, '0')
val remainder = (seconds % 60).toString().padStart(2, '0')
if (seconds >= 3600) "${seconds / 3600}:$minutes:$remainder" else "$minutes:$remainder"
}
val item = file.filePresentation(title, original, tags, code, resolution, folderHasVault, isEmptyFolder)
val hover = fileItemHover()
val coverShape = MaterialTheme.shapes.medium
Expand Down Expand Up @@ -157,7 +168,7 @@ internal fun PosterCard(
.wrapContentSize(Alignment.TopEnd),
)
}
if (code != null || resolution != null) {
if (code != null || resolution != null || durationLabel != null) {
// 左右两角放在同一行:窄卡片上长番号会碰到清晰度,同一行里番号先让出位置
Row(
modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().padding(6.dp),
Expand All @@ -168,6 +179,7 @@ internal fun PosterCard(
if (code != null) MediaTag(code, onMedia = true, emphasized = true)
}
if (resolution != null) MediaTag(resolution, onMedia = true)
if (durationLabel != null) MediaTag(durationLabel, onMedia = true)
}
}
}
Expand All @@ -186,6 +198,7 @@ internal fun PosterCard(
Column(Modifier.weight(1f)) {
FileItemTitle(item.name, style = MaterialTheme.typography.bodyMedium, maxLines = TITLE_LINES)
FileItemOriginal(item.name)
dev.piko.ui.components.FileAddedTime(file)
}
}
if (trailingShown && !isSelectionMode) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,8 @@ private val PlayContainerSize = DpSize(64.dp, 56.dp)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun PlayerBottomBar(
isPlaying: Boolean,
onPlayPause: () -> Unit,
isLandscape: Boolean,
isFullscreen: Boolean,
thumbOnHoverOnly: Boolean,
Expand Down Expand Up @@ -624,6 +626,13 @@ internal fun PlayerBottomBar(
verticalAlignment = Alignment.CenterVertically,
) {
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
PlayerIconButton(
icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
label = if (isPlaying) tr("暂停", "Pause", "一時停止", "일시 중지", "暫停") else tr("播放", "Play", "再生", "재생", "播放"),
onClick = onPlayPause,
shortcut = "Space",
)
Spacer(Modifier.width(8.dp))
// 换集在时间前面,照多数播放器底栏的排法。没有上一集或下一集时禁用而不是隐藏,时间不会左右跳
if (showEpisodeSkip) {
PlayerIconButton(icon = Icons.Filled.SkipPrevious, label = tr("上一集", "Previous episode", "前のエピソード", "이전 에피소드", "上一集"), onClick = onPrevious, enabled = hasPrevious, shortcut = "PageUp")
Expand Down
Loading
Loading