Skip to content

Commit 19ce9fb

Browse files
committed
Introduce job management with pagination and status tracking
1 parent 748ae59 commit 19ce9fb

17 files changed

Lines changed: 410 additions & 89 deletions

File tree

composeApp/src/commonMain/kotlin/ui/JobsScreen.kt

Lines changed: 144 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package ui
22

33
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.Box
45
import androidx.compose.foundation.layout.Column
56
import androidx.compose.foundation.layout.Row
67
import androidx.compose.foundation.layout.Spacer
@@ -12,13 +13,17 @@ import androidx.compose.foundation.layout.width
1213
import androidx.compose.foundation.rememberScrollState
1314
import androidx.compose.foundation.verticalScroll
1415
import androidx.compose.material.icons.Icons
16+
import androidx.compose.material.icons.automirrored.sharp.KeyboardArrowLeft
17+
import androidx.compose.material.icons.automirrored.sharp.KeyboardArrowRight
1518
import androidx.compose.material.icons.sharp.Close
1619
import androidx.compose.material3.Card
17-
import androidx.compose.material3.ElevatedButton
20+
import androidx.compose.material3.DropdownMenu
21+
import androidx.compose.material3.DropdownMenuItem
1822
import androidx.compose.material3.Icon
1923
import androidx.compose.material3.IconButton
2024
import androidx.compose.material3.LinearProgressIndicator
2125
import androidx.compose.material3.MaterialTheme
26+
import androidx.compose.material3.OutlinedButton
2227
import androidx.compose.material3.OutlinedCard
2328
import androidx.compose.material3.Text
2429
import androidx.compose.runtime.Composable
@@ -39,6 +44,7 @@ import io.github.jsixface.common.JobStatus.Failed
3944
import io.github.jsixface.common.JobStatus.InProgress
4045
import io.github.jsixface.common.JobStatus.Queued
4146
import io.github.jsixface.common.JobStatus.Starting
47+
import io.github.jsixface.common.JobsResponse
4248
import kotlinx.coroutines.launch
4349
import org.koin.compose.koinInject
4450
import ui.model.ModelState
@@ -58,26 +64,32 @@ fun JobsScreen() {
5864
) {
5965
val jobsScreenModel = koinInject<JobsScreenModel>()
6066
val scope = rememberCoroutineScope()
61-
var jobs by remember { mutableStateOf(listOf<ConversionJob>()) }
67+
var jobsResponse by remember { mutableStateOf<JobsResponse?>(null) }
6268
LaunchedEffect(Unit) {
6369
scope.launch {
64-
jobsScreenModel.jobs.collect { jobResult ->
70+
jobsScreenModel.jobsResponse.collect { jobResult ->
6571
when (jobResult) {
6672
is ModelState.Error, is ModelState.Init -> {}
67-
is ModelState.Success -> jobs = jobResult.result
73+
is ModelState.Success -> jobsResponse = jobResult.result
6874
}
6975
}
7076
}
7177
}
7278
JobContent(
73-
jobs,
74-
onClear = { scope.launch { jobsScreenModel.clearJobs() } },
79+
jobsResponse,
80+
onPageChange = { jobsScreenModel.setPage(it) },
81+
onItemsPerPageChange = { jobsScreenModel.setItemsPerPage(it) },
7582
onDelete = { scope.launch { jobsScreenModel.delete(it) } })
7683
}
7784
}
7885

7986
@Composable
80-
fun JobContent(jobs: List<ConversionJob>, onDelete: (String) -> Unit, onClear: () -> Unit) {
87+
fun JobContent(
88+
jobsResponse: JobsResponse?,
89+
onPageChange: (Int) -> Unit,
90+
onItemsPerPageChange: (Int) -> Unit,
91+
onDelete: (String) -> Unit
92+
) {
8193
Card(
8294
modifier = Modifier.width(width = 900.dp).fillMaxHeight().padding(20.dp)
8395
) {
@@ -91,16 +103,64 @@ fun JobContent(jobs: List<ConversionJob>, onDelete: (String) -> Unit, onClear: (
91103
)
92104
}
93105
Column {
94-
jobs.forEach { job -> JobItem(job) { onDelete(job.jobId) } }
106+
jobsResponse?.jobs?.forEach { job -> JobItem(job) { onDelete(job.jobId) } }
95107
}
96-
Row(modifier = Modifier.fillMaxSize()) {
97-
Spacer(modifier = Modifier.weight(1f))
98-
ElevatedButton(onClick = onClear, modifier = padding) {
99-
Text("Clear Completed")
108+
if (jobsResponse != null) {
109+
PaginationControls(
110+
totalItems = jobsResponse.totalCompleted,
111+
currentPage = jobsResponse.page,
112+
itemsPerPage = jobsResponse.itemsPerPage,
113+
onPageChange = onPageChange,
114+
onItemsPerPageChange = onItemsPerPageChange
115+
)
116+
}
117+
}
118+
}
119+
}
120+
121+
@Composable
122+
fun PaginationControls(
123+
totalItems: Long,
124+
currentPage: Int,
125+
itemsPerPage: Int,
126+
onPageChange: (Int) -> Unit,
127+
onItemsPerPageChange: (Int) -> Unit
128+
) {
129+
val totalPages = kotlin.math.ceil(totalItems.toDouble() / itemsPerPage).toInt().coerceAtLeast(1)
130+
Row(
131+
modifier = Modifier.fillMaxWidth().padding(16.dp),
132+
verticalAlignment = Alignment.CenterVertically,
133+
horizontalArrangement = Arrangement.Center
134+
) {
135+
Text("Items per page:")
136+
Spacer(modifier = Modifier.width(8.dp))
137+
var expanded by remember { mutableStateOf(false) }
138+
Box {
139+
OutlinedButton(onClick = { expanded = true }) {
140+
Text(itemsPerPage.toString())
141+
}
142+
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
143+
listOf(10, 20, 50, 100).forEach { limit ->
144+
DropdownMenuItem(
145+
text = { Text(limit.toString()) },
146+
onClick = {
147+
onItemsPerPageChange(limit)
148+
expanded = false
149+
}
150+
)
100151
}
101-
Spacer(modifier = Modifier.weight(1f))
102152
}
103153
}
154+
155+
Spacer(modifier = Modifier.weight(1f))
156+
157+
IconButton(onClick = { onPageChange(currentPage - 1) }, enabled = currentPage > 1) {
158+
Icon(Icons.AutoMirrored.Sharp.KeyboardArrowLeft, contentDescription = "Previous Page")
159+
}
160+
Text("Page $currentPage of $totalPages")
161+
IconButton(onClick = { onPageChange(currentPage + 1) }, enabled = currentPage < totalPages) {
162+
Icon(Icons.AutoMirrored.Sharp.KeyboardArrowRight, contentDescription = "Next Page")
163+
}
104164
}
105165
}
106166

@@ -115,30 +175,80 @@ fun JobItem(job: ConversionJob, onDelete: () -> Unit) {
115175
Column(modifier = Modifier.weight(1f).padding(8.dp)) {
116176
Text(job.file.fileName, modifier = paddingSmall)
117177
when (job.status) {
118-
Starting -> LinearProgressIndicator(modifier = progressPadding)
119-
InProgress -> LinearProgressIndicator(
120-
progress = { job.progress / 100.0f },
121-
modifier = progressPadding,
122-
)
178+
Starting -> Column {
179+
LinearProgressIndicator(modifier = progressPadding)
180+
Text(
181+
"Started at: ${job.startedAt}",
182+
style = MaterialTheme.typography.labelSmall,
183+
modifier = paddingSmall
184+
)
185+
}
186+
187+
InProgress -> Column {
188+
LinearProgressIndicator(
189+
progress = { job.progress / 100.0f },
190+
modifier = progressPadding,
191+
)
192+
Text(
193+
"Started at: ${job.startedAt}",
194+
style = MaterialTheme.typography.labelSmall,
195+
modifier = paddingSmall
196+
)
197+
}
123198

124-
Completed -> Text(
125-
"Completed",
126-
style = MaterialTheme.typography.labelSmall,
127-
modifier = paddingSmall
128-
)
199+
Completed -> Row {
200+
Text(
201+
"Completed",
202+
style = MaterialTheme.typography.labelSmall,
203+
modifier = paddingSmall
204+
)
205+
Text(
206+
"Started at: ${job.startedAt}",
207+
style = MaterialTheme.typography.labelSmall,
208+
modifier = paddingSmall
209+
)
210+
job.duration?.let {
211+
Text(
212+
"Duration: $it",
213+
style = MaterialTheme.typography.labelSmall,
214+
modifier = paddingSmall
215+
)
216+
}
217+
}
129218

130-
Failed -> Text(
131-
"Failed",
132-
style = MaterialTheme.typography.labelSmall,
133-
color = MaterialTheme.colorScheme.error,
134-
modifier = paddingSmall
135-
)
219+
Failed -> Row {
220+
Text(
221+
"Failed",
222+
style = MaterialTheme.typography.labelSmall,
223+
color = MaterialTheme.colorScheme.error,
224+
modifier = paddingSmall
225+
)
226+
Text(
227+
"Started at: ${job.startedAt}",
228+
style = MaterialTheme.typography.labelSmall,
229+
modifier = paddingSmall
230+
)
231+
job.duration?.let {
232+
Text(
233+
"Duration: $it",
234+
style = MaterialTheme.typography.labelSmall,
235+
modifier = paddingSmall
236+
)
237+
}
238+
}
136239

137-
Queued -> Text(
138-
"Queued",
139-
style = MaterialTheme.typography.labelSmall,
140-
modifier = paddingSmall
141-
)
240+
Queued -> Column {
241+
Text(
242+
"Queued",
243+
style = MaterialTheme.typography.labelSmall,
244+
modifier = paddingSmall
245+
)
246+
Text(
247+
"Started at: ${job.startedAt}",
248+
style = MaterialTheme.typography.labelSmall,
249+
modifier = paddingSmall
250+
)
251+
}
142252
}
143253
}
144254
if (job.status !in listOf(Failed, Completed)) {
Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
package viewmodels
22

33
import io.github.jsixface.common.Api
4-
import io.github.jsixface.common.ConversionJob
4+
import io.github.jsixface.common.JobsResponse
55
import io.ktor.client.HttpClient
66
import io.ktor.client.call.body
77
import io.ktor.client.plugins.resources.delete
88
import io.ktor.client.plugins.resources.get
99
import io.ktor.http.isSuccess
1010
import kotlin.time.Duration.Companion.seconds
1111
import kotlinx.coroutines.delay
12+
import kotlinx.coroutines.flow.MutableStateFlow
13+
import kotlinx.coroutines.flow.asStateFlow
1214
import kotlinx.coroutines.flow.flow
1315
import ui.model.ModelState
1416
import ui.model.ModelState.Error
@@ -17,15 +19,30 @@ import ui.model.ModelState.Success
1719
import util.log
1820

1921
class JobsScreenModel(private val client: HttpClient) {
22+
private val _page = MutableStateFlow(1)
23+
val page = _page.asStateFlow()
24+
25+
private val _itemsPerPage = MutableStateFlow(10)
26+
val itemsPerPage = _itemsPerPage.asStateFlow()
27+
28+
fun setPage(page: Int) {
29+
_page.value = page
30+
}
31+
32+
fun setItemsPerPage(limit: Int) {
33+
_itemsPerPage.value = limit
34+
_page.value = 1
35+
}
2036

2137
init {
2238
log("New JobsScreenModel")
2339
}
2440

25-
val jobs = flow<ModelState<List<ConversionJob>>> {
41+
val jobsResponse = flow<ModelState<JobsResponse>> {
2642
emit(Init())
2743
while (true) {
28-
val result = kotlin.runCatching { client.get(Api.Jobs) }.getOrNull()
44+
val result =
45+
kotlin.runCatching { client.get(Api.Jobs(page = _page.value, limit = _itemsPerPage.value)) }.getOrNull()
2946
log("Got result: $result")
3047
if (result?.status?.isSuccess() == true) emit(Success(result.body()))
3148
else emit(Error("Error. Status: ${result?.status}"))
@@ -36,8 +53,4 @@ class JobsScreenModel(private val client: HttpClient) {
3653
suspend fun delete(jobId: String) {
3754
client.delete(Api.Jobs.Job(id = jobId))
3855
}
39-
40-
suspend fun clearJobs() {
41-
client.delete(Api.Jobs)
42-
}
4356
}

server/src/main/kotlin/io/github/jsixface/codexvert/api/ConversionApi.kt

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,23 @@ package io.github.jsixface.codexvert.api
22

33
import io.github.jsixface.codexvert.logger
44
import io.github.jsixface.common.Conversion
5+
import io.github.jsixface.common.ConversionJob
6+
import io.github.jsixface.common.JobStatus
57
import io.github.jsixface.common.MediaTrack
68
import io.github.jsixface.common.VideoFile
79
import io.ktor.utils.io.CancellationException
10+
import java.io.File
11+
import java.io.InputStream
12+
import java.io.UncheckedIOException
13+
import java.nio.file.Files
14+
import java.util.UUID
15+
import kotlin.io.path.Path
16+
import kotlin.time.Clock
17+
import kotlin.time.Duration
18+
import kotlin.time.Duration.Companion.milliseconds
19+
import kotlin.time.Duration.Companion.seconds
20+
import kotlin.time.ExperimentalTime
21+
import kotlin.time.measureTime
822
import kotlinx.coroutines.CoroutineScope
923
import kotlinx.coroutines.Dispatchers
1024
import kotlinx.coroutines.Job
@@ -17,19 +31,11 @@ import kotlinx.coroutines.launch
1731
import kotlinx.datetime.LocalDateTime
1832
import kotlinx.datetime.TimeZone
1933
import kotlinx.datetime.toLocalDateTime
20-
import java.io.File
21-
import java.io.InputStream
22-
import java.io.UncheckedIOException
23-
import java.nio.file.Files
24-
import java.util.UUID
25-
import kotlin.io.path.Path
26-
import kotlin.time.Clock
27-
import kotlin.time.Duration
28-
import kotlin.time.Duration.Companion.milliseconds
29-
import kotlin.time.Duration.Companion.seconds
30-
import kotlin.time.ExperimentalTime
3134

32-
class ConversionApi(private val preferences: IPreferences) {
35+
class ConversionApi(
36+
private val preferences: IPreferences,
37+
private val jobsRepo: io.github.jsixface.codexvert.db.IJobsRepo
38+
) {
3339
private val logger = logger()
3440
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
3541
val jobs = mutableListOf<ConvertingJob>()
@@ -47,7 +53,28 @@ class ConversionApi(private val preferences: IPreferences) {
4753
if (jobs.none { it.job?.isActive == true }) {
4854
logger.info("Starting conversion for ${nextJob.videoFile}")
4955
with(nextJob) {
50-
job = launch { startJob(videoFile, convSpecs, outFile, progress) }
56+
job = launch {
57+
val duration = measureTime {
58+
startJob(videoFile, convSpecs, outFile, progress)
59+
}
60+
val status = if (progress.value == 100) JobStatus.Completed else JobStatus.Failed
61+
val startedTimeStr = "${startedAt.date} ${
62+
startedAt.time.hour.toString().padStart(2, '0')
63+
}:${
64+
startedAt.time.minute.toString().padStart(2, '0')
65+
}:${startedAt.time.second.toString().padStart(2, '0')}"
66+
jobsRepo.save(
67+
ConversionJob(
68+
jobId = jobId,
69+
status = status,
70+
progress = progress.value,
71+
file = videoFile,
72+
startedAt = startedTimeStr
73+
),
74+
duration.toIsoString()
75+
)
76+
jobs.remove(nextJob)
77+
}
5178
}
5279
}
5380
}
@@ -56,10 +83,6 @@ class ConversionApi(private val preferences: IPreferences) {
5683
}
5784
}
5885

59-
fun clearFinished() {
60-
jobs.removeAll { it.progress.value == -1 || it.progress.value == 100 }
61-
}
62-
6386
fun startConversion(file: VideoFile, convSpecs: Map<MediaTrack, Conversion>): Boolean {
6487
val jobId = UUID.randomUUID().toString()
6588
val newDir = File(workspace, jobId).apply { mkdirs() }

0 commit comments

Comments
 (0)