Skip to content
Closed
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 @@ -22,6 +22,7 @@ import me.him188.ani.app.data.repository.SavedWindowState
import me.him188.ani.app.data.repository.media.MediaSourceSaves
import me.him188.ani.app.data.repository.media.MediaSourceSubscriptionsSaveData
import me.him188.ani.app.data.repository.media.MikanIndexes
import me.him188.ani.app.data.repository.player.EpisodeLocalFileBindings
import me.him188.ani.app.data.repository.player.EpisodeHistories
import me.him188.ani.app.data.repository.torrent.peer.PeerFilterSubscriptionsSaveData
import me.him188.ani.app.data.repository.user.TokenSave
Expand Down Expand Up @@ -73,6 +74,17 @@ abstract class PlatformDataStoreManager {
)
}

val episodeLocalFileBindingStore by lazy {
DataStoreFactory.create(
serializer = EpisodeLocalFileBindings.serializer()
.asDataStoreSerializer({ EpisodeLocalFileBindings.Empty }),
produceFile = { resolveDataStoreFile("episodeLocalFileBindings") },
corruptionHandler = ReplaceFileCorruptionHandler {
EpisodeLocalFileBindings.Empty
},
)
}

// creata a datastore<List<DanmakuFilter>>
val danmakuFilterStore by lazy {
DataStoreFactory.create(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* Copyright (C) 2024-2026 OpenAni and contributors.
*
* 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
* Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link.
*
* https://github.com/open-ani/ani/blob/main/LICENSE
*/

package me.him188.ani.app.data.repository.player

import androidx.datastore.core.DataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import me.him188.ani.app.data.repository.Repository
import me.him188.ani.datasources.api.SubtitleKind

@Serializable
data class EpisodeLocalFileBinding(
val subjectId: Int,
val episodeId: Int,
val filePath: String,
val displayName: String,
val subtitleLanguageIds: List<String> = emptyList(),
val resolution: String = "",
val alliance: String = "",
val subtitleKind: SubtitleKind? = null,
)

@Serializable
data class EpisodeLocalFileBindings(
val bindings: List<EpisodeLocalFileBinding> = emptyList(),
) {
companion object {
val Empty = EpisodeLocalFileBindings(emptyList())
}
}

interface EpisodeLocalFileBindingRepository {
val flow: Flow<List<EpisodeLocalFileBinding>>

fun bindingFlow(subjectId: Int, episodeId: Int): Flow<EpisodeLocalFileBinding?>

suspend fun save(binding: EpisodeLocalFileBinding)

suspend fun remove(subjectId: Int, episodeId: Int): Boolean

suspend fun get(subjectId: Int, episodeId: Int): EpisodeLocalFileBinding?
}

class EpisodeLocalFileBindingRepositoryImpl(
private val dataStore: DataStore<EpisodeLocalFileBindings>,
) : Repository(), EpisodeLocalFileBindingRepository {
override val flow: Flow<List<EpisodeLocalFileBinding>> = dataStore.data.map { it.bindings }

override fun bindingFlow(subjectId: Int, episodeId: Int): Flow<EpisodeLocalFileBinding?> {
return flow.map { bindings ->
bindings.firstOrNull { it.subjectId == subjectId && it.episodeId == episodeId }
}
}

override suspend fun save(binding: EpisodeLocalFileBinding) {
dataStore.updateData { current ->
current.copy(
bindings = current.bindings
.filterNot { it.subjectId == binding.subjectId && it.episodeId == binding.episodeId }
.plus(binding),
)
}
}

override suspend fun remove(subjectId: Int, episodeId: Int): Boolean {
var removed = false
dataStore.updateData { current ->
val filtered = current.bindings.filterNot {
val shouldRemove = it.subjectId == subjectId && it.episodeId == episodeId
removed = removed || shouldRemove
shouldRemove
}
current.copy(bindings = filtered)
}
return removed
}

override suspend fun get(subjectId: Int, episodeId: Int): EpisodeLocalFileBinding? {
return bindingFlow(subjectId, episodeId).firstOrNull()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/*
* Copyright (C) 2024-2026 OpenAni and contributors.
*
* 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
* Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link.
*
* https://github.com/open-ani/ani/blob/main/LICENSE
*/

package me.him188.ani.app.domain.media.fetch

import kotlinx.coroutines.flow.asFlow
import kotlinx.io.files.Path
import me.him188.ani.app.data.repository.player.EpisodeLocalFileBinding
import me.him188.ani.app.data.repository.player.EpisodeLocalFileBindingRepository
import me.him188.ani.datasources.api.DefaultMedia
import me.him188.ani.datasources.api.MediaProperties
import me.him188.ani.datasources.api.paging.SinglePagePagedSource
import me.him188.ani.datasources.api.paging.SizedSource
import me.him188.ani.datasources.api.source.ConnectionStatus
import me.him188.ani.datasources.api.source.MatchKind
import me.him188.ani.datasources.api.source.MediaFetchRequest
import me.him188.ani.datasources.api.source.MediaMatch
import me.him188.ani.datasources.api.source.MediaSource
import me.him188.ani.datasources.api.source.MediaSourceInfo
import me.him188.ani.datasources.api.source.MediaSourceKind
import me.him188.ani.datasources.api.source.MediaSourceLocation
import me.him188.ani.datasources.api.topic.EpisodeRange
import me.him188.ani.datasources.api.topic.FileSize.Companion.bytes
import me.him188.ani.datasources.api.topic.ResourceLocation
import me.him188.ani.utils.io.SystemPath
import me.him188.ani.utils.io.exists
import me.him188.ani.utils.io.extension
import me.him188.ani.utils.io.inSystem
import me.him188.ani.utils.io.isRegularFile
import me.him188.ani.utils.io.length
import me.him188.ani.utils.io.name

class LocalEpisodeFileBindingMediaSource(
private val repository: EpisodeLocalFileBindingRepository,
) : MediaSource {
override val mediaSourceId: String = ID
override val location: MediaSourceLocation = MediaSourceLocation.Local
override val kind: MediaSourceKind = MediaSourceKind.LocalCache
override val info: MediaSourceInfo = MediaSourceInfo(
displayName = "本地绑定",
description = "手动绑定到剧集的本地视频文件",
isSpecial = true,
)

override suspend fun checkConnection(): ConnectionStatus = ConnectionStatus.SUCCESS

override suspend fun fetch(query: MediaFetchRequest): SizedSource<MediaMatch> {
return SinglePagePagedSource {
val subjectId = query.subjectId.toIntOrNull()
val episodeId = query.episodeId.toIntOrNull()
if (subjectId == null || episodeId == null) {
return@SinglePagePagedSource emptyList<MediaMatch>().asFlow()
}

val binding = repository.get(subjectId, episodeId)
?: return@SinglePagePagedSource emptyList<MediaMatch>().asFlow()
val file = Path(binding.filePath).inSystem
if (!file.exists() || !file.isRegularFile()) {
return@SinglePagePagedSource emptyList<MediaMatch>().asFlow()
}

listOf(
MediaMatch(
createMedia(binding, query, file),
MatchKind.EXACT,
),
).asFlow()
}
}

private fun createMedia(
binding: EpisodeLocalFileBinding,
query: MediaFetchRequest,
file: SystemPath,
): DefaultMedia {
val download = ResourceLocation.LocalFile(
filePath = binding.filePath,
fileType = inferFileType(file),
)
return DefaultMedia(
mediaId = "$ID:${query.subjectId}:${query.episodeId}:${binding.filePath}",
mediaSourceId = ID,
originalUrl = download.uri,
download = download,
originalTitle = binding.displayName.ifBlank { file.name },
publishedTime = 0L,
properties = MediaProperties(
subjectName = query.subjectNameCN ?: query.subjectNames.firstOrNull(),
episodeName = query.episodeName,
subtitleLanguageIds = binding.subtitleLanguageIds,
resolution = binding.resolution,
alliance = binding.alliance.ifBlank { DEFAULT_ALLIANCE },
size = file.length().bytes,
subtitleKind = binding.subtitleKind,
),
episodeRange = EpisodeRange.single(query.episodeEp ?: query.episodeSort),
location = MediaSourceLocation.Local,
kind = MediaSourceKind.LocalCache,
)
}

private fun inferFileType(file: SystemPath): ResourceLocation.LocalFile.FileType {
return when (file.extension.lowercase()) {
"ts", "m2ts", "mts" -> ResourceLocation.LocalFile.FileType.MPTS
else -> ResourceLocation.LocalFile.FileType.CONTAINED
}
}

companion object {
const val ID: String = "local-episode-binding"
const val DEFAULT_ALLIANCE: String = "本地文件"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ import me.him188.ani.app.data.repository.media.MikanIndexCacheRepositoryImpl
import me.him188.ani.app.data.repository.media.SelectorMediaSourceEpisodeCacheRepository
import me.him188.ani.app.data.repository.player.DanmakuRegexFilterRepository
import me.him188.ani.app.data.repository.player.DanmakuRegexFilterRepositoryImpl
import me.him188.ani.app.data.repository.player.EpisodeLocalFileBindingRepository
import me.him188.ani.app.data.repository.player.EpisodeLocalFileBindingRepositoryImpl
import me.him188.ani.app.data.repository.player.EpisodePlayHistoryRepository
import me.him188.ani.app.data.repository.player.EpisodePlayHistoryRepositoryImpl
import me.him188.ani.app.data.repository.player.EpisodeScreenshotRepository
Expand Down Expand Up @@ -109,6 +111,7 @@ import me.him188.ani.app.domain.media.cache.engine.TorrentMediaCacheEngine
import me.him188.ani.app.domain.media.cache.storage.HttpMediaCacheStorage
import me.him188.ani.app.domain.media.cache.storage.MediaSaveDirProvider
import me.him188.ani.app.domain.media.cache.storage.TorrentMediaCacheStorage
import me.him188.ani.app.domain.media.fetch.LocalEpisodeFileBindingMediaSource
import me.him188.ani.app.domain.media.fetch.MediaSourceManager
import me.him188.ani.app.domain.media.fetch.MediaSourceManagerImpl
import me.him188.ani.app.domain.mediasource.codec.MediaSourceCodecManager
Expand Down Expand Up @@ -335,6 +338,9 @@ private fun KoinApplication.otherModules(getContext: () -> Context, coroutineSco
single<EpisodePlayHistoryRepository> {
EpisodePlayHistoryRepositoryImpl(getContext().dataStores.episodeHistoryStore)
}
single<EpisodeLocalFileBindingRepository> {
EpisodeLocalFileBindingRepositoryImpl(getContext().dataStores.episodeLocalFileBindingStore)
}
single<AniSubjectRelationIndexService> {
val provider = get<AniApiProvider>()
AniSubjectRelationIndexService(provider.subjectRelationsApi)
Expand Down Expand Up @@ -462,10 +468,14 @@ private fun KoinApplication.otherModules(getContext: () -> Context, coroutineSco
single<MediaSourceCodecManager> {
MediaSourceCodecManager()
}
single {
LocalEpisodeFileBindingMediaSource(get())
}
single<MediaSourceManager> {
MediaSourceManagerImpl(
additionalSources = {
get<MediaCacheManager>().storagesIncludingDisabled.map { it.cacheMediaSource }
listOf(get<LocalEpisodeFileBindingMediaSource>()) +
get<MediaCacheManager>().storagesIncludingDisabled.map { it.cacheMediaSource }
},
)
}
Expand Down
2 changes: 2 additions & 0 deletions app/shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ kotlin {
api(libs.coil.svg)
api(libs.coil.compose.core)
implementation(libs.constraintlayout.compose)
implementation(libs.filekit.dialogs)
implementation(libs.filekit.dialogs.compose)
}

// shared by android and desktop
Expand Down
12 changes: 12 additions & 0 deletions app/shared/src/commonMain/kotlin/ui/subject/episode/EpisodePage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ private fun EpisodeScreenTabletVeryWide(
0 -> Box(Modifier.fillMaxSize()) {
val navigator = LocalNavigator.current
val pageState by vm.pageState.collectAsStateWithLifecycle()
val localFileBinding by vm.localFileBindingFlow.collectAsStateWithLifecycle()
val toaster = LocalToaster.current
pageState?.let { page ->
EpisodeDetails(
Expand All @@ -512,6 +513,9 @@ private fun EpisodeScreenTabletVeryWide(
page.mediaSelectorState,
{ page.mediaSourceResultListPresentation },
page.selfInfo,
hasLocalFileBinding = localFileBinding != null,
onBindLocalFile = { vm.bindLocalFile(it) },
onClearLocalFileBinding = { vm.clearLocalFileBinding() },
modifier = Modifier.fillMaxSize(),
onSwitchEpisode = { episodeId ->
if (!vm.episodeSelectorState.selectEpisodeId(episodeId)) {
Expand Down Expand Up @@ -663,6 +667,7 @@ private fun EpisodeScreenContentPhone(
episodeDetails = {
val navigator = LocalNavigator.current
val pageState by vm.pageState.collectAsStateWithLifecycle()
val localFileBinding by vm.localFileBindingFlow.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()

pageState?.let { page ->
Expand All @@ -679,6 +684,9 @@ private fun EpisodeScreenContentPhone(
page.mediaSelectorState,
{ page.mediaSourceResultListPresentation },
page.selfInfo,
hasLocalFileBinding = localFileBinding != null,
onBindLocalFile = { vm.bindLocalFile(it) },
onClearLocalFileBinding = { vm.clearLocalFileBinding() },
onSwitchEpisode = { episodeId ->
if (!vm.episodeSelectorState.selectEpisodeId(episodeId)) {
navigator.navigateEpisodeDetails(vm.subjectId, episodeId)
Expand Down Expand Up @@ -1036,6 +1044,7 @@ private fun EpisodeVideo(
},
mediaSelectorPage = {
val pageState by vm.pageState.collectAsStateWithLifecycle()
val localFileBinding by vm.localFileBindingFlow.collectAsStateWithLifecycle()
pageState?.let { page ->
val (viewKind, onViewKindChange) = rememberSaveable { mutableStateOf(page.initialMediaSelectorViewKind) }
EpisodeVideoSideSheets.MediaSelectorSheet(
Expand All @@ -1048,6 +1057,9 @@ private fun EpisodeVideo(
onDismissRequest = { goBack() },
onRefresh = { vm.refreshFetch() },
onRestartSource = { vm.restartSource(it) },
hasLocalFileBinding = localFileBinding != null,
onBindLocalFile = { vm.bindLocalFile(it) },
onClearLocalFileBinding = { vm.clearLocalFileBinding() },
)
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,9 @@ private fun PreviewVideoScaffoldImpl(
onDismissRequest = { goBack() },
onRefresh = {},
onRestartSource = {},
hasLocalFileBinding = false,
onBindLocalFile = { false },
onClearLocalFileBinding = { false },
)
},
episodeSelectorPage = {
Expand Down
Loading