Prepare repository for public release
Build server image / build (push) Failing after 2s

Remove embedded credentials and private deployment configuration; document the remaining publication checks.
This commit is contained in:
Nicholas Ward committed 2026-09-18 15:19:41 -05:00
commit 2dfa4e7563
54 files changed
+3579

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Generate a unique value, for example: openssl rand -hex 32
API_TOKEN=replace-with-a-long-random-token
# Directory containing the videos exposed by the server.
MEDIA_PATH=./sample-media
+36
View File
@@ -0,0 +1,36 @@
name: Build server image
on:
push:
branches:
- master
workflow_dispatch:
jobs:
build:
runs-on: shared-ci
env:
IMAGE_REGISTRY: ${{ vars.IMAGE_REGISTRY }}
IMAGE_NAME: ${{ vars.IMAGE_NAME }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.IMAGE_REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build image
run: docker build --tag "$IMAGE_REGISTRY/$IMAGE_NAME:${{ github.sha }}" --tag "$IMAGE_REGISTRY/$IMAGE_NAME:latest" server
- name: Push image
run: |
docker push "$IMAGE_REGISTRY/$IMAGE_NAME:${{ github.sha }}"
docker push "$IMAGE_REGISTRY/$IMAGE_NAME:latest"
- name: Prune images
if: always()
run: docker image prune --force
+15
View File
@@ -0,0 +1,15 @@
.gradle/
.idea/
*.iml
local.properties
android/app/build/
android/build/
server/short-form-video-server
catalog-data/
sample-media/*
!sample-media/.gitkeep
.env
.env.*
!.env.example
*.jks
*.keystore
+21
View File
@@ -0,0 +1,21 @@
# Public release checklist
Complete these items while the repository is still private:
- [ ] Choose a license for the original code and add it as `LICENSE`.
- [ ] Rotate the bearer token that was previously embedded in the application and
deployment configuration.
- [x] Rewrite Git history to remove the old bearer token and application PIN,
and replace the original commit author email with a noreply identity.
- [ ] Configure the Gitea Actions variables and secrets documented in `README.md`,
and verify the image build in a clean checkout.
- [ ] Review repository-adjacent data that is not in Git: issues, pull requests,
releases, packages, wiki pages, webhooks, deploy keys, collaborators, and
Actions logs/artifacts.
- [ ] Run a credential scanner against the rewritten history and inspect the final
tracked-file list before changing visibility.
- [ ] Enable appropriate branch protection and decide whether public issue and
pull-request creation should be enabled.
Changing repository visibility is intentionally not part of this checklist's
automated preparation.
+86
View File
@@ -0,0 +1,86 @@
# short-form-video
A rapid prototype for browsing a private video library as a thumbnail grid and
playing its videos in a TikTok-style vertical pager. Content identity is
independent of filesystem layout, so videos can be moved or renamed safely.
## Run the server
The server reads its media directory through a read-only bind mount. Create a
local configuration file, generate a unique bearer token, and start it with:
```sh
cp .env.example .env
sed -i "s/replace-with-a-long-random-token/$(openssl rand -hex 32)/" .env
docker compose up --build
```
Edit `MEDIA_PATH` in `.env` if your videos are not in `sample-media`.
The `.env` file is ignored by Git; never commit a real API token.
The API is available at `http://localhost:8080`, requires bearer authentication,
and rescans every minute.
You can also override either value for a single invocation:
```sh
API_TOKEN="$(openssl rand -hex 32)" MEDIA_PATH="$PWD/sample-media" docker compose up --build
```
The SQLite catalog lives in the `catalog-data` Docker volume. Video files are
mounted read-only. SHA-256 content identity survives file moves and renames.
Identical files are represented as one logical video. Each scan synchronizes
read-only collections with the directories containing the videos.
## Run the Android app
Open the repository in Android Studio and run the `android:app` configuration,
or build from a shell with JDK 17 and an Android SDK:
```sh
./gradlew :android:app:assembleDebug
```
Use `http://10.0.2.2:8080` from the Android emulator. On a physical device, use
the development computer's LAN address, such as `http://192.168.1.20:8080`.
Enter the same API token when connecting. The app stores its connection settings
in its private application data and excludes them from Android backups. Cleartext
HTTP is permitted only in debug builds; use HTTPS for non-local deployments.
Tap a grid tile to open the vertical player. Long-press to select videos for a
manual collection. The pager prepares neighboring pages to reduce startup latency.
Swipe horizontally between the library and collections, or pull down to refresh.
## Development checks
```sh
cd server && go test ./...
docker compose config --quiet
./gradlew :android:app:assembleDebug
```
`ffmpeg` and `ffprobe` are included in the server image for thumbnails and media
metadata. The server otherwise uses Go's standard library plus one SQLite driver.
## Container publishing
The Gitea workflow expects these repository settings:
- Variables: `IMAGE_REGISTRY` and `IMAGE_NAME`
- Secrets: `REGISTRY_USERNAME` and `REGISTRY_PASSWORD`
Forks do not inherit those secrets. Configure them before enabling the workflow
on a new repository.
## Security
Treat the bearer token as a password. The server provides a single shared token,
not per-user accounts or authorization scopes, and should not be exposed directly
to the internet without HTTPS and appropriate network controls.
## Licensing
Third-party licenses and attributions are recorded in
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md). No license has yet been granted
for the repository's original code; choose and add one before announcing the
project as open source.
+43
View File
@@ -0,0 +1,43 @@
# Third-party notices
The Android interface draws visual and interaction inspiration from
[DaChelimo/TikTok-Clone](https://github.com/DaChelimo/TikTok-Clone), used under
the MIT License.
Copyright (c) 2021 Andre-max
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Playback pooling, caching, lifecycle, and retry patterns are adapted
from [manjees/compose-reels](https://github.com/manjees/compose-reels),
Copyright 2024 manjees, licensed under the Apache License, Version 2.0.
Additional Android Compose presentation patterns and icons are adapted from
[puskal-khadka/TikTok-Compose](https://github.com/puskal-khadka/TikTok-Compose),
Copyright 2023 Puskal Khadka, licensed under the Apache License, Version 2.0.
The application bundles TikTok Sans from
[tiktok/TikTokSans](https://github.com/tiktok/TikTokSans), Copyright 2024
TikTok Inc., licensed under the SIL Open Font License, Version 1.1. The full
font license is included in `android/app/src/main/res/raw/tiktok_sans_license.txt`.
The collection checkbox sheet is adapted from
[cnrture/PickerSheet](https://github.com/cnrture/PickerSheet), licensed under
the Apache License, Version 2.0. Its picker layout, drag handle, title, lazy
list, checkbox row, spacing, and navigation-bar treatment are retained.
+51
View File
@@ -0,0 +1,51 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.uuard.shortformvideo"
compileSdk = 36
defaultConfig {
applicationId = "com.uuard.shortformvideo"
minSdk = 23
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
debug { manifestPlaceholders["usesCleartextTraffic"] = "true" }
release {
isMinifyEnabled = true
manifestPlaceholders["usesCleartextTraffic"] = "false"
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
}
buildFeatures { compose = true }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = "17" }
}
dependencies {
val media3 = "1.11.1"
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.compose.material3:material3:1.3.2")
implementation("androidx.compose.foundation:foundation:1.8.1")
implementation("androidx.compose.ui:ui:1.8.1")
implementation("androidx.compose.ui:ui-tooling-preview:1.8.1")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("androidx.media3:media3-exoplayer:$media3")
implementation("androidx.media3:media3-database:$media3")
implementation("androidx.media3:media3-datasource:$media3")
implementation("androidx.media3:media3-ui:$media3")
implementation("io.coil-kt.coil3:coil-compose:3.2.0")
implementation("io.coil-kt.coil3:coil-network-okhttp:3.2.0")
debugImplementation("androidx.compose.ui:ui-tooling:1.8.1")
}
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:label="Short Form Video"
android:supportsRtl="true"
android:theme="@style/Theme.ShortFormVideo"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,89 @@
package com.uuard.shortformvideo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URI
import java.net.URLEncoder
class ApiClient(serverUrl: String, private val apiToken: String) {
private val base = serverUrl.trimEnd('/') + "/"
suspend fun videos(): List<Video> = withContext(Dispatchers.IO) {
buildList {
var cursor = "0"
do {
val root = get("api/v1/videos?limit=100&cursor=$cursor")
val values = root.getJSONArray("items")
repeat(values.length()) { add(parseVideo(values.getJSONObject(it))) }
cursor = root.optString("next_cursor")
} while (cursor.isNotEmpty())
}
}
suspend fun checkConnection() = withContext(Dispatchers.IO) {
val response = get("healthz")
check(response.optString("status") == "ok") { "Server returned an invalid health response" }
get("api/v1/collections")
}
suspend fun collections(): List<VideoCollection> = withContext(Dispatchers.IO) {
val values = get("api/v1/collections").getJSONArray("items")
(0 until values.length()).map { index ->
val value = values.getJSONObject(index)
VideoCollection(value.getString("id"), value.getString("name"), value.getInt("video_count"), value.optString("cover_url").takeIf(String::isNotBlank)?.let(::resolve), value.optBoolean("managed"), value.optString("source_path").takeIf(String::isNotBlank))
}
}
suspend fun collectionVideos(id: String): List<Video> = withContext(Dispatchers.IO) {
val values = get("api/v1/collections/${encode(id)}/videos").getJSONArray("items")
(0 until values.length()).map { parseVideo(values.getJSONObject(it)) }
}
suspend fun videoCollectionIds(videoId: String): Set<String> = withContext(Dispatchers.IO) {
val values = get("api/v1/videos/${encode(videoId)}/collections").getJSONArray("collection_ids")
(0 until values.length()).map(values::getString).toSet()
}
suspend fun createCollection(name: String): VideoCollection = withContext(Dispatchers.IO) {
val value = send("api/v1/collections", "POST", JSONObject().put("name", name).toString().encodeToByteArray())!!
VideoCollection(value.getString("id"), value.getString("name"), value.optInt("video_count"), null, false, null)
}
suspend fun deleteCollection(id: String) = withContext(Dispatchers.IO) { send("api/v1/collections/${encode(id)}", "DELETE") }
suspend fun setVideoInCollection(collectionId: String, videoId: String, included: Boolean) = withContext(Dispatchers.IO) {
send("api/v1/collections/${encode(collectionId)}/videos/${encode(videoId)}", if (included) "PUT" else "DELETE")
}
private fun get(path: String): JSONObject = connection(path).run {
requestMethod = "GET"
try {
check(responseCode in 200..299) { errorText(this) }
JSONObject(inputStream.bufferedReader().use { it.readText() })
} finally { disconnect() }
}
private fun send(path: String, method: String, body: ByteArray? = null): JSONObject? = connection(path).run {
requestMethod = method
if (body != null) { doOutput = true; setRequestProperty("Content-Type", "application/json"); outputStream.use { it.write(body) } }
try {
check(responseCode in 200..299) { errorText(this) }
if (responseCode == 204) null else JSONObject(inputStream.bufferedReader().use { it.readText() })
} finally { disconnect() }
}
private fun parseVideo(value: JSONObject) = Video(
id = value.getString("id"), title = value.getString("title"), durationMs = value.getLong("duration_ms"),
playbackUrl = resolve(value.getString("playback_url")), thumbnailUrl = resolve(value.getString("thumbnail_url")),
)
private fun connection(path: String) = URI(base).resolve(path).toURL().openConnection().let { it as HttpURLConnection }.apply {
connectTimeout = 5_000
readTimeout = 15_000
setRequestProperty("Authorization", "Bearer $apiToken")
}
private fun resolve(path: String) = URI(base).resolve(path.removePrefix("/")).toString()
private fun encode(value: String) = URLEncoder.encode(value, Charsets.UTF_8.name())
private fun errorText(connection: HttpURLConnection) = "HTTP ${connection.responseCode}: " + connection.errorStream?.bufferedReader()?.use { it.readText() }
}
@@ -0,0 +1,754 @@
package com.uuard.shortformvideo
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerDefaults
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TextButton
import androidx.compose.material3.Text
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.lifecycle.compose.LifecycleStartEffect
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import java.net.URI
import com.uuard.shortformvideo.ui.components.VideoPage
import com.uuard.shortformvideo.ui.components.PlayerOverlay
import com.uuard.shortformvideo.ui.components.VideoThumbnail
import com.uuard.shortformvideo.ui.theme.TikTokColors
import com.uuard.shortformvideo.playback.PlaybackPool
import coil3.compose.AsyncImage
import androidx.media3.exoplayer.ExoPlayer
@Composable
fun SettingsScreen(
initialUrl: String,
initialToken: String,
canCancel: Boolean,
onCancel: () -> Unit,
onSave: (String, String) -> Unit,
) {
var value by remember { mutableStateOf(initialUrl) }
var token by remember { mutableStateOf(initialToken) }
var error by remember { mutableStateOf<String?>(null) }
var connecting by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val normalized = value.trim().trimEnd('/')
val valid = remember(normalized) { isValidServerUrl(normalized) }
Box(
modifier = Modifier.fillMaxSize().safeDrawingPadding().imePadding().padding(24.dp),
contentAlignment = Alignment.Center,
) {
Card(
modifier = Modifier.fillMaxWidth().widthIn(max = 520.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
) {
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
Text("Short Form Video", style = MaterialTheme.typography.headlineMedium)
Text(
"Connect to the computer or server containing your video library.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = value,
onValueChange = { value = it },
label = { Text("Server URL") },
supportingText = error?.let { message ->
{ Text(message, color = MaterialTheme.colorScheme.error) }
},
isError = error != null,
singleLine = true,
enabled = !connecting,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = token,
onValueChange = { token = it.trim() },
label = { Text("API token") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
enabled = !connecting,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = {
if (!valid) {
error = "Enter a complete HTTP or HTTPS server URL"
} else {
connecting = true
error = null
scope.launch {
runCatching { ApiClient(normalized, token).checkConnection() }
.onSuccess { onSave(normalized, token) }
.onFailure { error = it.message ?: "Could not connect to this server" }
connecting = false
}
}
},
enabled = valid && token.isNotBlank() && !connecting,
modifier = Modifier.fillMaxWidth().height(52.dp),
) {
if (connecting) CircularProgressIndicator(Modifier.size(22.dp), strokeWidth = 2.dp, color = Color.White)
else Text("Connect")
}
if (canCancel) TextButton(onClick = onCancel, enabled = !connecting, modifier = Modifier.fillMaxWidth()) { Text("Cancel") }
}
}
}
}
fun isValidServerUrl(value: String): Boolean = runCatching {
val uri = URI(value)
(uri.scheme == "http" || uri.scheme == "https") &&
!uri.host.isNullOrBlank() && uri.rawQuery == null && uri.rawFragment == null && uri.rawUserInfo == null
}.getOrDefault(false)
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ShortFormVideoApp(serverUrl: String, apiToken: String, editServer: () -> Unit, disconnect: () -> Unit) {
CompositionLocalProvider(LocalApiToken provides apiToken) {
val api = remember(serverUrl, apiToken) { ApiClient(serverUrl, apiToken) }
var videos by remember { mutableStateOf<List<Video>>(emptyList()) }
var selectedVideo by remember { mutableStateOf<Video?>(null) }
var collections by remember { mutableStateOf<List<VideoCollection>>(emptyList()) }
var selectedCollection by remember { mutableStateOf<VideoCollection?>(null) }
var collectionVideos by remember { mutableStateOf<List<Video>>(emptyList()) }
var creatingCollection by remember { mutableStateOf(false) }
var selectedVideoIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var gridMenuExpanded by remember { mutableStateOf(false) }
var mainMenuExpanded by remember { mutableStateOf(false) }
var collectionMenuExpanded by remember { mutableStateOf(false) }
var addingVideos by remember { mutableStateOf<List<Video>?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
var refreshingPage by remember { mutableStateOf<Int?>(null) }
var showLoading by remember { mutableStateOf(false) }
val libraryGridState = rememberLazyGridState()
val collectionsGridState = rememberLazyGridState()
val collectionGridState = rememberLazyGridState()
val scope = rememberCoroutineScope()
val libraryPagerState = rememberPagerState(pageCount = { 2 })
val showingCollections = libraryPagerState.currentPage == 1
BackHandler(selectedVideo != null) { selectedVideo = null }
BackHandler(selectedVideo == null && selectedVideoIds.isNotEmpty()) { selectedVideoIds = emptySet() }
BackHandler(selectedVideo == null && selectedVideoIds.isEmpty() && selectedCollection != null) { selectedCollection = null }
LaunchedEffect(loading) {
showLoading = false
if (loading) {
delay(200)
showLoading = true
}
}
LaunchedEffect(api) {
loading = true
error = null
runCatching {
videos = api.videos()
collections = api.collections()
}.onFailure { error = it.message }
loading = false
}
if (selectedVideo != null) {
PlayerScreen(
if (selectedCollection != null) collectionVideos else videos,
selectedVideo!!,
api = api,
apiToken = apiToken,
sourceCollection = selectedCollection?.takeUnless { it.managed },
onCollectionsChanged = { collections = api.collections() },
onRemovedFromCollection = { video ->
selectedCollection?.let { api.setVideoInCollection(it.id, video.id, false) }
selectedCollection?.let { collectionVideos = api.collectionVideos(it.id) }
collections = api.collections()
selectedVideo = null
},
onBack = { selectedVideo = null },
)
} else {
LaunchedEffect(selectedCollection?.id) {
selectedCollection?.let { collectionVideos = api.collectionVideos(it.id) }
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text(if (selectedVideoIds.isNotEmpty()) "${selectedVideoIds.size} selected" else selectedCollection?.name ?: "Library", fontWeight = FontWeight.Bold) },
navigationIcon = {
when {
selectedVideoIds.isNotEmpty() -> IconButton(onClick = { selectedVideoIds = emptySet() }) {
Icon(painterResource(R.drawable.ic_cancel), "Cancel selection", Modifier.size(20.dp))
}
selectedCollection != null -> IconButton(onClick = { selectedCollection = null }) {
Icon(painterResource(R.drawable.ic_arrow_back), "Back", Modifier.size(27.dp))
}
}
},
actions = {
when {
selectedVideoIds.isNotEmpty() -> IconButton(onClick = { gridMenuExpanded = true }) {
Icon(painterResource(R.drawable.ic_more_vert), "More options", Modifier.size(24.dp))
}
selectedCollection?.managed == false -> IconButton(onClick = { collectionMenuExpanded = true }) {
Icon(painterResource(R.drawable.ic_more_vert), "Collection options", Modifier.size(24.dp))
}
else -> IconButton(onClick = { mainMenuExpanded = true }) {
Icon(painterResource(R.drawable.ic_hamburger), "Settings", Modifier.size(24.dp))
}
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
)
},
) { insets ->
Column(Modifier.fillMaxSize().padding(insets)) {
if (selectedCollection == null && selectedVideoIds.isEmpty()) {
TabRow(
selectedTabIndex = if (showingCollections) 1 else 0,
divider = { HorizontalDivider(thickness = .5.dp, color = MaterialTheme.colorScheme.outlineVariant) },
) {
listOf(R.drawable.ic_play_outline, R.drawable.ic_bookmark).forEachIndexed { index, icon ->
val selected = (if (showingCollections) 1 else 0) == index
Tab(
selected = selected,
onClick = { scope.launch { libraryPagerState.animateScrollToPage(index) } },
icon = {
Icon(
painterResource(icon),
if (index == 0) "Videos" else "Collections",
Modifier.size(if (index == 0) 20.dp else 22.dp),
tint = if (selected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
}
}
}
Box(Modifier.fillMaxSize()) {
if (selectedCollection == null) {
HorizontalPager(state = libraryPagerState, modifier = Modifier.fillMaxSize()) { page ->
PullToRefreshBox(
isRefreshing = refreshingPage == page,
onRefresh = {
scope.launch {
refreshingPage = page
runCatching {
if (page == 0) videos = api.videos()
collections = api.collections()
}.onFailure { error = it.message }
refreshingPage = null
}
},
) {
if (page == 0) {
VideoGrid(videos, libraryGridState, selectedVideoIds, { selectedVideoIds = it }, { selectedVideo = it })
} else {
LazyVerticalGrid(columns = GridCells.Fixed(2), state = collectionsGridState, modifier = Modifier.fillMaxSize()) {
item(key = "new-collection") { NewCollectionTile { creatingCollection = true } }
items(collections, key = { it.id }) { collection ->
CollectionTile(collection, onClick = { selectedCollection = collection })
}
}
}
}
}
} else {
PullToRefreshBox(
isRefreshing = refreshingPage == 2,
onRefresh = {
scope.launch {
refreshingPage = 2
runCatching {
collectionVideos = api.collectionVideos(selectedCollection!!.id)
collections = api.collections()
}.onFailure { error = it.message }
refreshingPage = null
}
},
) {
VideoGrid(collectionVideos, collectionGridState, selectedVideoIds, { selectedVideoIds = it }, { selectedVideo = it })
}
}
if (showLoading) CircularProgressIndicator(Modifier.align(Alignment.Center).size(32.dp), strokeWidth = 2.5.dp)
if (!loading && !showingCollections && videos.isEmpty() && error == null) {
Column(Modifier.align(Alignment.Center).padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("No videos found", style = MaterialTheme.typography.titleLarge)
Text("Add MP4 or WebM files to the server, then wait for its next scan.", style = MaterialTheme.typography.bodyMedium)
}
}
error?.let { message ->
Card(Modifier.align(Alignment.Center).padding(24.dp)) {
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Could not load videos", style = MaterialTheme.typography.titleMedium)
Text(message, color = MaterialTheme.colorScheme.error)
Button(onClick = editServer, modifier = Modifier.fillMaxWidth()) { Text("Change server") }
}
}
}
}
}
}
}
if (creatingCollection) {
CollectionNameDialog(onDismiss = { creatingCollection = false }) { name ->
scope.launch { api.createCollection(name); collections = api.collections(); creatingCollection = false }
}
}
addingVideos?.let { selectedVideos ->
CollectionPickerSheet(api, selectedVideos, onDismiss = { addingVideos = null }) {
collections = api.collections(); selectedVideoIds = emptySet(); addingVideos = null
selectedCollection?.let { collectionVideos = api.collectionVideos(it.id) }
}
}
if (gridMenuExpanded) OverflowCollectionSheet(
onDismiss = { gridMenuExpanded = false },
onAddToCollection = {
val source = if (selectedCollection != null) collectionVideos else videos
addingVideos = source.filter { it.id in selectedVideoIds }
gridMenuExpanded = false
},
onRemoveFromCollection = selectedCollection?.takeUnless { it.managed }?.let { collection ->
{
val ids = selectedVideoIds
gridMenuExpanded = false
scope.launch {
ids.forEach { api.setVideoInCollection(collection.id, it, false) }
collectionVideos = api.collectionVideos(collection.id)
collections = api.collections()
selectedVideoIds = emptySet()
}
}
},
)
if (mainMenuExpanded) MainMenuSheet(
serverUrl = serverUrl,
onDismiss = { mainMenuExpanded = false },
onServerSettings = { mainMenuExpanded = false; editServer() },
onDisconnect = { mainMenuExpanded = false; disconnect() },
)
if (collectionMenuExpanded) CollectionMenuSheet(
onDismiss = { collectionMenuExpanded = false },
onDelete = {
collectionMenuExpanded = false
scope.launch { api.deleteCollection(selectedCollection!!.id); collections = api.collections(); selectedCollection = null }
},
)
}
}
@Composable
private fun VideoGrid(
videos: List<Video>,
state: LazyGridState,
selectedVideoIds: Set<String>,
onSelectionChanged: (Set<String>) -> Unit,
onOpen: (Video) -> Unit,
) {
LazyVerticalGrid(columns = GridCells.Fixed(3), state = state, modifier = Modifier.fillMaxSize()) {
items(videos, key = { it.id }) { video ->
VideoThumbnail(
video = video,
selected = video.id in selectedVideoIds,
onLongClick = { onSelectionChanged(selectedVideoIds + video.id) },
onClick = {
if (selectedVideoIds.isNotEmpty()) {
onSelectionChanged(if (video.id in selectedVideoIds) selectedVideoIds - video.id else selectedVideoIds + video.id)
} else onOpen(video)
},
)
}
}
}
@Composable
private fun NewCollectionTile(onClick: () -> Unit) {
Column(Modifier.padding(6.dp).clickable(onClick = onClick)) {
Box(
Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(8.dp)).background(TikTokColors.LightGray),
contentAlignment = Alignment.Center,
) {
Text("+", style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text("New collection", fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 6.dp))
}
}
@Composable
private fun CollectionTile(collection: VideoCollection, onClick: () -> Unit) {
Column(Modifier.padding(6.dp).clickable(onClick = onClick)) {
Box(Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(8.dp)).background(TikTokColors.LightGray)) {
collection.coverUrl?.let { AsyncImage(authenticatedImage(LocalContext.current, it), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
Text("${collection.videoCount}", color = Color.White, modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp).background(Color.Black.copy(alpha=.55f), RoundedCornerShape(12.dp)).padding(horizontal=8.dp, vertical=3.dp))
}
Text(collection.sourcePath ?: collection.name, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 6.dp))
}
}
@Composable
private fun CollectionNameDialog(onDismiss: () -> Unit, onCreate: (String) -> Unit) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New collection") },
text = { OutlinedTextField(name, { name = it }, label = { Text("Collection name") }, singleLine = true) },
confirmButton = { TextButton(onClick = { onCreate(name.trim()) }, enabled = name.isNotBlank()) { Text("Create") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun PlayerScreen(
videos: List<Video>,
selected: Video,
api: ApiClient,
apiToken: String,
sourceCollection: VideoCollection?,
onCollectionsChanged: suspend () -> Unit,
onRemovedFromCollection: suspend (Video) -> Unit,
onBack: () -> Unit,
) {
val start = videos.indexOfFirst { it.id == selected.id }.coerceAtLeast(0)
val midpoint = Int.MAX_VALUE / 2
val initialPage = midpoint - midpoint % videos.size + start
val pagerState = rememberPagerState(initialPage = initialPage, pageCount = { Int.MAX_VALUE })
var playbackVideos by remember(videos) { mutableStateOf(videos) }
val scope = rememberCoroutineScope()
var isMuted by rememberSaveable { mutableStateOf(false) }
var loopVideo by rememberSaveable { mutableStateOf(true) }
var shuffled by remember { mutableStateOf(false) }
val context = LocalContext.current
val playbackPool = remember(context, apiToken) { PlaybackPool(context, apiToken) }
var managingVideo by remember { mutableStateOf<Video?>(null) }
var playerMenuExpanded by remember { mutableStateOf(false) }
var controlsVisible by remember { mutableStateOf(false) }
var activePlayer by remember { mutableStateOf<ExoPlayer?>(null) }
var paused by remember { mutableStateOf(false) }
var scrubbing by remember { mutableStateOf(false) }
val view = LocalView.current
DisposableEffect(view, paused) {
view.keepScreenOn = !paused
onDispose { view.keepScreenOn = false }
}
LaunchedEffect(pagerState.currentPage) {
controlsVisible = true
paused = false
scrubbing = false
}
LifecycleStartEffect(playbackPool) {
onStopOrDispose { playbackPool.pauseAll() }
}
DisposableEffect(playbackPool) {
onDispose { playbackPool.releaseAll() }
}
Box(Modifier.fillMaxSize().background(Color.Black)) {
VerticalPager(
state = pagerState,
beyondViewportPageCount = 2,
flingBehavior = PagerDefaults.flingBehavior(
state = pagerState,
snapAnimationSpec = tween(durationMillis = 210),
snapPositionalThreshold = .22f,
),
) { page ->
val video = playbackVideos[page % playbackVideos.size]
VideoPage(
video = video,
isActive = pagerState.currentPage == page,
paused = paused,
isMuted = isMuted,
loopVideo = loopVideo,
controlsVisible = controlsVisible,
onControlsVisibilityChange = { controlsVisible = it },
onPlayerActive = { activePlayer = it },
onVideoEnded = {
if (!loopVideo && pagerState.currentPage == page && videos.size > 1) {
scope.launch { pagerState.animateScrollToPage(page + 1) }
}
},
playbackPool = playbackPool,
)
}
activePlayer?.let { player ->
PlayerOverlay(
player = player,
visible = controlsVisible,
paused = paused,
muted = isMuted,
loop = loopVideo,
shuffled = shuffled,
scrubbing = scrubbing,
onScrubbingChange = { scrubbing = it },
onTogglePaused = { paused = !paused },
onToggleMuted = { isMuted = !isMuted },
onToggleLoop = { loopVideo = !loopVideo },
onToggleShuffle = {
val index = pagerState.currentPage % playbackVideos.size
val current = playbackVideos[index]
val source = if (shuffled) videos else listOf(current) + videos.filterNot { it.id == current.id }.shuffled()
val anchor = source.indexOfFirst { it.id == current.id }
playbackVideos = List(source.size) { offset -> source[(anchor + offset - index).mod(source.size)] }
shuffled = !shuffled
},
)
}
AnimatedVisibility(
visible = controlsVisible,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.TopCenter),
) {
Box(
Modifier.fillMaxWidth().safeDrawingPadding().padding(horizontal = 14.dp, vertical = 8.dp),
) {
IconButton(
onClick = onBack,
modifier = Modifier.align(Alignment.CenterStart).size(48.dp).background(Color.Black.copy(alpha = .48f), CircleShape),
) {
Icon(painterResource(R.drawable.ic_arrow_back), "Back", Modifier.size(27.dp), tint = Color.White)
}
IconButton(
onClick = { playerMenuExpanded = true },
modifier = Modifier.align(Alignment.CenterEnd).size(48.dp).background(Color.Black.copy(alpha = .48f), CircleShape),
) {
Icon(painterResource(R.drawable.ic_more_vert), "More options", Modifier.size(24.dp), tint = Color.White)
}
}
}
}
managingVideo?.let { video ->
CollectionPickerSheet(api, listOf(video), onDismiss = { managingVideo = null }, onChanged = onCollectionsChanged)
}
if (playerMenuExpanded) OverflowCollectionSheet(
onDismiss = { playerMenuExpanded = false },
onAddToCollection = { managingVideo = playbackVideos[pagerState.currentPage % playbackVideos.size]; playerMenuExpanded = false },
onRemoveFromCollection = sourceCollection?.let {
{
val video = playbackVideos[pagerState.currentPage % playbackVideos.size]
playerMenuExpanded = false
scope.launch { onRemovedFromCollection(video) }
}
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun OverflowCollectionSheet(
onDismiss: () -> Unit,
onAddToCollection: () -> Unit,
onRemoveFromCollection: (() -> Unit)? = null,
) {
ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surface) {
Column(Modifier.fillMaxWidth().navigationBarsPadding()) {
Row(
Modifier.fillMaxWidth().clickable(onClick = onAddToCollection).padding(horizontal = 24.dp, vertical = 18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Icon(painterResource(R.drawable.ic_bookmark), null, Modifier.size(26.dp), tint = MaterialTheme.colorScheme.onSurface)
Text("Add to collection", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
}
onRemoveFromCollection?.let { remove ->
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = .55f))
Row(
Modifier.fillMaxWidth().clickable(onClick = remove).padding(horizontal = 24.dp, vertical = 18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Icon(painterResource(R.drawable.ic_bookmark), null, Modifier.size(26.dp), tint = MaterialTheme.colorScheme.error)
Text("Remove from collection", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.error)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainMenuSheet(serverUrl: String, onDismiss: () -> Unit, onServerSettings: () -> Unit, onDisconnect: () -> Unit) {
ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surface) {
Column(Modifier.fillMaxWidth().navigationBarsPadding()) {
Text(
"Settings",
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
Row(
Modifier.fillMaxWidth().clickable(onClick = onServerSettings).padding(horizontal = 24.dp, vertical = 18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Column(Modifier.weight(1f)) {
Text("Server settings", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
Text(serverUrl, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Text("›", style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = .55f))
Row(
Modifier.fillMaxWidth().clickable(onClick = onDisconnect).padding(horizontal = 24.dp, vertical = 18.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Disconnect server", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.error)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CollectionMenuSheet(onDismiss: () -> Unit, onDelete: () -> Unit) {
ModalBottomSheet(onDismissRequest = onDismiss, containerColor = MaterialTheme.colorScheme.surface) {
Row(
Modifier.fillMaxWidth().clickable(onClick = onDelete).padding(horizontal = 24.dp, vertical = 18.dp).navigationBarsPadding(),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Delete collection", style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.error)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CollectionPickerSheet(api: ApiClient, videos: List<Video>, onDismiss: () -> Unit, onChanged: suspend () -> Unit) {
var collections by remember { mutableStateOf<List<VideoCollection>>(emptyList()) }
var selected by remember { mutableStateOf<Set<String>>(emptySet()) }
var creating by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
LaunchedEffect(videos) {
collections = api.collections()
selected = if (videos.size == 1) api.videoCollectionIds(videos.first().id) else emptySet()
}
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = onDismiss,
dragHandle = { BottomSheetDefaults.DragHandle() },
containerColor = MaterialTheme.colorScheme.surface,
scrimColor = Color.Gray.copy(alpha = .6f),
) {
Column(Modifier.fillMaxWidth().navigationBarsPadding()) {
Text(
if (videos.size == 1) "Save to collection" else "Save ${videos.size} videos to",
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
LazyColumn {
items(collections.filterNot { it.managed }, key = { it.id }) { collection ->
val checked = collection.id in selected
Row(
Modifier.fillMaxWidth().clickable {
val include = !checked
selected = if (include) selected + collection.id else selected - collection.id
scope.launch {
videos.forEach { api.setVideoInCollection(collection.id, it.id, include) }
onChanged()
}
},
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked, onCheckedChange = null)
Text(collection.name, modifier = Modifier.weight(1f).padding(vertical = 16.dp), style = MaterialTheme.typography.bodyMedium)
Text("${collection.videoCount}", color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 20.dp))
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = .55f))
}
item {
Row(
Modifier.fillMaxWidth().clickable { creating = true }.padding(horizontal = 16.dp, vertical = 18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Text("+", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.headlineMedium)
Text("Create new collection", fontWeight = FontWeight.SemiBold)
}
}
}
}
}
if (creating) CollectionNameDialog(onDismiss = { creating = false }) { name ->
scope.launch {
val collection = api.createCollection(name)
videos.forEach { api.setVideoInCollection(collection.id, it.id, true) }
collections = api.collections(); selected = selected + collection.id; creating = false; onChanged()
}
}
}
@@ -0,0 +1,16 @@
package com.uuard.shortformvideo
import android.content.Context
import coil3.network.NetworkHeaders
import coil3.network.httpHeaders
import coil3.request.ImageRequest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
internal val LocalApiToken = staticCompositionLocalOf<String> { error("API token not provided") }
@Composable
internal fun authenticatedImage(context: Context, url: String) = ImageRequest.Builder(context)
.data(url)
.httpHeaders(NetworkHeaders.Builder().set("Authorization", "Bearer ${LocalApiToken.current}").build())
.build()
@@ -0,0 +1,59 @@
package com.uuard.shortformvideo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.enableEdgeToEdge
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.uuard.shortformvideo.ui.theme.TikTokTheme
class MainActivity : ComponentActivity() {
private var serverUrl by mutableStateOf("")
private var apiToken by mutableStateOf("")
private var editingServer by mutableStateOf(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val preferences = getSharedPreferences("settings", MODE_PRIVATE)
serverUrl = preferences.getString("server_url", "") ?: ""
apiToken = preferences.getString("api_token", "") ?: ""
setContent {
TikTokTheme {
Surface(Modifier.fillMaxSize()) {
if (serverUrl.isBlank() || apiToken.isBlank() || editingServer || !isValidServerUrl(serverUrl)) {
SettingsScreen(
initialUrl = serverUrl,
initialToken = apiToken,
canCancel = serverUrl.isNotBlank(),
onCancel = { editingServer = false },
onSave = { url, token ->
serverUrl = url
apiToken = token
preferences.edit().putString("server_url", url).putString("api_token", token).apply()
editingServer = false
},
)
} else {
ShortFormVideoApp(
serverUrl = serverUrl,
apiToken = apiToken,
editServer = { editingServer = true },
disconnect = {
preferences.edit().remove("server_url").remove("api_token").apply()
serverUrl = ""
apiToken = ""
editingServer = false
},
)
}
}
}
}
}
}
@@ -0,0 +1,18 @@
package com.uuard.shortformvideo
data class Video(
val id: String,
val title: String,
val durationMs: Long,
val playbackUrl: String,
val thumbnailUrl: String,
)
data class VideoCollection(
val id: String,
val name: String,
val videoCount: Int,
val coverUrl: String?,
val managed: Boolean,
val sourcePath: String?,
)
@@ -0,0 +1,92 @@
package com.uuard.shortformvideo.playback
import android.content.Context
import androidx.annotation.OptIn
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import java.io.File
import java.util.ArrayDeque
@OptIn(UnstableApi::class)
class PlaybackPool(context: Context, apiToken: String, private val maxSize: Int = 5) {
private val appContext = context.applicationContext
private val available = ArrayDeque<ExoPlayer>()
private val inUse = mutableSetOf<ExoPlayer>()
private val mediaSourceFactory = DefaultMediaSourceFactory(VideoCache.factory(appContext, apiToken))
@Synchronized
fun acquire(url: String): ExoPlayer {
val player = if (available.isNotEmpty()) available.removeFirst() else createPlayer()
inUse += player
player.setMediaItem(MediaItem.fromUri(url))
player.prepare()
return player
}
@Synchronized
fun release(player: ExoPlayer) {
if (!inUse.remove(player)) return
player.stop()
player.clearMediaItems()
if (available.size < maxSize) available.addLast(player) else player.release()
}
@Synchronized
fun pauseAll() = inUse.forEach(Player::pause)
@Synchronized
fun releaseAll() {
available.forEach(ExoPlayer::release)
inUse.forEach(ExoPlayer::release)
available.clear()
inUse.clear()
}
private fun createPlayer() = ExoPlayer.Builder(appContext)
.setMediaSourceFactory(mediaSourceFactory)
.setLoadControl(
DefaultLoadControl.Builder()
.setBufferDurationsMs(
5_000,
10_000,
500,
1_000,
)
.build(),
)
.build()
.apply { playWhenReady = false }
}
@OptIn(UnstableApi::class)
private object VideoCache {
private const val CACHE_BYTES = 1024L * 1024 * 1024
@Volatile private var cache: SimpleCache? = null
fun factory(context: Context, apiToken: String): CacheDataSource.Factory {
val sharedCache = cache ?: synchronized(this) {
cache ?: SimpleCache(
File(context.cacheDir, "video_cache").apply(File::mkdirs),
LeastRecentlyUsedCacheEvictor(CACHE_BYTES),
StandaloneDatabaseProvider(context),
).also { cache = it }
}
val http = DefaultHttpDataSource.Factory()
.setAllowCrossProtocolRedirects(true)
.setDefaultRequestProperties(mapOf("Authorization" to "Bearer $apiToken"))
return CacheDataSource.Factory()
.setCache(sharedCache)
.setUpstreamDataSourceFactory(DefaultDataSource.Factory(context, http))
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
}
@@ -0,0 +1,413 @@
package com.uuard.shortformvideo.ui.components
import android.view.ViewGroup
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.Canvas
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.CircleShape
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.uuard.shortformvideo.R
import com.uuard.shortformvideo.Video
import com.uuard.shortformvideo.playback.PlaybackPool
import kotlinx.coroutines.delay
import kotlin.math.roundToLong
@Composable
fun VideoPage(
video: Video,
isActive: Boolean,
paused: Boolean,
isMuted: Boolean,
loopVideo: Boolean,
controlsVisible: Boolean,
onControlsVisibilityChange: (Boolean) -> Unit,
onPlayerActive: (ExoPlayer) -> Unit,
onVideoEnded: () -> Unit,
playbackPool: PlaybackPool,
modifier: Modifier = Modifier,
) {
var seekFeedback by remember(video.id) { mutableStateOf<SeekFeedback?>(null) }
var isBuffering by remember(video.id) { mutableStateOf(true) }
var showBuffering by remember(video.id) { mutableStateOf(false) }
var playbackError by remember(video.id) { mutableStateOf<String?>(null) }
val currentOnVideoEnded by rememberUpdatedState(onVideoEnded)
val player = remember(video.id, playbackPool) { playbackPool.acquire(video.playbackUrl) }
LaunchedEffect(isActive, player) { if (isActive) onPlayerActive(player) }
DisposableEffect(player, playbackPool) { onDispose { playbackPool.release(player) } }
LaunchedEffect(player, isMuted) { player.volume = if (isMuted) 0f else 1f }
LaunchedEffect(player, loopVideo) {
player.repeatMode = if (loopVideo) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
}
DisposableEffect(player) {
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
isBuffering = state == Player.STATE_BUFFERING
if (state == Player.STATE_READY) playbackError = null
if (state == Player.STATE_ENDED) currentOnVideoEnded()
}
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
isBuffering = false
playbackError = error.message ?: "This video could not be played"
}
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
}
LaunchedEffect(isBuffering, isActive, playbackError) {
showBuffering = false
if (isBuffering && isActive && playbackError == null) {
delay(300)
showBuffering = true
}
}
Box(modifier = modifier.fillMaxSize()) {
VideoPlayer(
player = player,
playWhenReady = isActive && !paused,
modifier = Modifier.fillMaxSize(),
)
Box(
modifier = Modifier.fillMaxSize().pointerInput(video.id, controlsVisible) {
detectTapGestures(
onTap = {
onControlsVisibilityChange(!controlsVisible)
},
onDoubleTap = { tap ->
val direction = if (tap.x < size.width / 2f) -1 else 1
val target = (player.currentPosition + direction * DOUBLE_TAP_SEEK_MS)
.coerceIn(0L, player.duration.takeIf { it > 0L } ?: Long.MAX_VALUE)
player.seekTo(target)
seekFeedback = if (direction < 0) SeekFeedback.Rewind else SeekFeedback.Forward
},
)
},
)
LaunchedEffect(seekFeedback) {
if (seekFeedback != null) {
delay(650)
seekFeedback = null
}
}
seekFeedback?.let { feedback ->
Box(
modifier = Modifier
.align(if (feedback == SeekFeedback.Rewind) Alignment.CenterStart else Alignment.CenterEnd)
.padding(horizontal = 42.dp)
.size(80.dp)
.background(Color.Black.copy(alpha = 0.42f), androidx.compose.foundation.shape.CircleShape),
contentAlignment = Alignment.Center,
) {
Text(
text = if (feedback == SeekFeedback.Rewind) "−5s" else "+5s",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
}
if (showBuffering) {
Box(
Modifier.align(Alignment.Center).size(56.dp).background(Color.Black.copy(alpha = .48f), CircleShape),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(Modifier.size(28.dp), color = Color.White, strokeWidth = 2.5.dp)
}
}
playbackError?.let { message ->
Column(
modifier = Modifier.align(Alignment.Center)
.background(Color.Black.copy(alpha = 0.72f), MaterialTheme.shapes.medium)
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Playback failed", color = Color.White, fontWeight = FontWeight.Bold)
Text(message, color = Color.White.copy(alpha = .8f), style = MaterialTheme.typography.bodySmall)
Surface(
color = Color.White,
contentColor = Color.Black,
shape = MaterialTheme.shapes.extraLarge,
modifier = Modifier.clickable {
playbackError = null
isBuffering = true
player.prepare()
if (isActive && !paused) player.play()
},
) { Text("Retry", modifier = Modifier.padding(horizontal = 24.dp, vertical = 10.dp), fontWeight = FontWeight.Bold) }
}
}
if (!controlsVisible) {
VideoInfoOverlay(video = video, modifier = Modifier.align(Alignment.BottomStart).fillMaxWidth().navigationBarsPadding())
}
}
}
@Composable
fun PlayerOverlay(
player: ExoPlayer,
visible: Boolean,
paused: Boolean,
muted: Boolean,
loop: Boolean,
shuffled: Boolean,
scrubbing: Boolean,
onScrubbingChange: (Boolean) -> Unit,
onTogglePaused: () -> Unit,
onToggleMuted: () -> Unit,
onToggleLoop: () -> Unit,
onToggleShuffle: () -> Unit,
) {
if (visible || scrubbing) {
TikTokScrubber(
player = player,
onScrubbingChanged = onScrubbingChange,
modifier = Modifier.fillMaxSize().navigationBarsPadding().padding(bottom = 70.dp),
)
}
AnimatedVisibility(
visible = visible && !scrubbing,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.fillMaxSize(),
) {
Box(Modifier.fillMaxSize()) {
Row(
Modifier.align(Alignment.BottomCenter).fillMaxWidth().navigationBarsPadding().padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
PlaybackControl(if (paused) R.drawable.ic_round_play else R.drawable.ic_pause, if (paused) "Resume playback" else "Pause playback", true, onTogglePaused)
PlaybackTimestamp(player)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PlaybackControl(if (muted) R.drawable.ic_volume_off else R.drawable.ic_volume_up, if (muted) "Unmute" else "Mute", !muted, onToggleMuted)
PlaybackControl(R.drawable.ic_repeat_one, "Toggle loop or auto-advance", loop, onToggleLoop)
PlaybackControl(R.drawable.ic_shuffle, "Toggle shuffle", shuffled, onToggleShuffle)
}
}
}
}
}
@Composable
private fun PlaybackControl(icon: Int, description: String, selected: Boolean = false, onClick: () -> Unit) {
val color = if (selected) Color.White else Color(0xFF8A8B8F)
Surface(
modifier = Modifier.size(56.dp).semantics { contentDescription = description }.clickable(onClick = onClick),
shape = CircleShape,
color = Color.Black.copy(alpha = .48f),
contentColor = color,
) {
Box(contentAlignment = Alignment.Center) {
Icon(painterResource(icon), null, Modifier.size(30.dp), tint = color)
}
}
}
@Composable
private fun PlaybackTimestamp(player: ExoPlayer) {
var position by remember(player) { mutableLongStateOf(0L) }
var duration by remember(player) { mutableLongStateOf(0L) }
LaunchedEffect(player) {
while (true) {
position = player.currentPosition.coerceAtLeast(0L)
duration = player.duration.takeIf { it > 0L } ?: 0L
delay(250)
}
}
Text(
"${formatPlaybackTime(position)} / ${formatPlaybackTime(duration)}",
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
}
private enum class SeekFeedback { Rewind, Forward }
private const val DOUBLE_TAP_SEEK_MS = 5_000L
@Composable
private fun VideoInfoOverlay(video: Video, modifier: Modifier = Modifier) {
Text(
video.title,
modifier.padding(start = 16.dp, end = 64.dp, bottom = 16.dp),
color = Color.White,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
@Composable
@OptIn(UnstableApi::class)
private fun VideoPlayer(
player: ExoPlayer,
playWhenReady: Boolean,
modifier: Modifier = Modifier,
) {
LaunchedEffect(player, playWhenReady) { player.playWhenReady = playWhenReady }
val lifecycleOwner = LocalLifecycleOwner.current
val currentPlayWhenReady by rememberUpdatedState(playWhenReady)
DisposableEffect(lifecycleOwner, player) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> player.pause()
Lifecycle.Event.ON_RESUME -> player.playWhenReady = currentPlayWhenReady
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
AndroidView(
modifier = modifier,
factory = { context ->
PlayerView(context).apply {
this.player = player
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
}
},
update = { view ->
view.player = player
view.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
},
)
}
@Composable
private fun TikTokScrubber(
player: ExoPlayer,
onScrubbingChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
var position by remember(player) { mutableLongStateOf(0L) }
var duration by remember(player) { mutableLongStateOf(0L) }
var buffered by remember(player) { mutableLongStateOf(0L) }
var scrubbing by remember(player) { mutableStateOf(false) }
var resumeAfterScrub by remember(player) { mutableStateOf(false) }
LaunchedEffect(player, scrubbing) {
while (true) {
duration = player.duration.takeIf { it > 0 } ?: 0L
buffered = player.bufferedPosition.coerceAtLeast(0L)
if (!scrubbing) position = player.currentPosition.coerceAtLeast(0L)
delay(100)
}
}
if (duration <= 0L) return
Box(modifier = modifier) {
Canvas(
modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(40.dp).pointerInput(player, duration) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
resumeAfterScrub = player.playWhenReady
player.pause()
scrubbing = true
onScrubbingChanged(true)
fun seek(x: Float) {
val fraction = (x / size.width).coerceIn(0f, 1f)
position = (duration * fraction).roundToLong()
player.seekTo(position)
}
seek(down.position.x)
do {
val event = awaitPointerEvent()
val change = event.changes.first()
if (change.positionChange() != androidx.compose.ui.geometry.Offset.Zero) change.consume()
seek(change.position.x)
} while (change.pressed)
scrubbing = false
onScrubbingChanged(false)
if (resumeAfterScrub) player.play()
}
},
) {
val progress = (position.toFloat() / duration).coerceIn(0f, 1f)
val bufferProgress = (buffered.toFloat() / duration).coerceIn(0f, 1f)
val y = size.height / 2f
val stroke = if (scrubbing) 6.dp.toPx() else 4.dp.toPx()
drawLine(Color.White.copy(alpha = 0.16f), start = androidx.compose.ui.geometry.Offset(0f, y), end = androidx.compose.ui.geometry.Offset(size.width, y), strokeWidth = stroke)
drawLine(Color.White.copy(alpha = 0.34f), start = androidx.compose.ui.geometry.Offset(0f, y), end = androidx.compose.ui.geometry.Offset(size.width * bufferProgress, y), strokeWidth = stroke)
drawLine(Color.White, start = androidx.compose.ui.geometry.Offset(0f, y), end = androidx.compose.ui.geometry.Offset(size.width * progress, y), strokeWidth = stroke)
if (scrubbing) drawCircle(Color.White, radius = 6.dp.toPx(), center = androidx.compose.ui.geometry.Offset(size.width * progress, y))
}
if (scrubbing) {
Text(
text = "${formatPlaybackTime(position)} / ${formatPlaybackTime(duration)}",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.align(Alignment.Center)
.background(Color.Black.copy(alpha = 0.45f)).padding(horizontal = 10.dp, vertical = 5.dp),
)
}
}
}
private fun formatPlaybackTime(milliseconds: Long): String {
val seconds = milliseconds.coerceAtLeast(0L) / 1000
return "%d:%02d".format(seconds / 60, seconds % 60)
}
@@ -0,0 +1,71 @@
package com.uuard.shortformvideo.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.shape.CircleShape
import coil3.compose.AsyncImage
import com.uuard.shortformvideo.Video
import com.uuard.shortformvideo.authenticatedImage
private val ThumbnailShape = RoundedCornerShape(4.dp)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun VideoThumbnail(
video: Video,
onClick: () -> Unit,
modifier: Modifier = Modifier,
onLongClick: () -> Unit = {},
selected: Boolean = false,
) {
Box(
modifier = modifier
.aspectRatio(9f / 16f)
.padding(1.dp)
.clip(ThumbnailShape)
.background(Color.DarkGray)
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
) {
AsyncImage(
model = authenticatedImage(LocalContext.current, video.thumbnailUrl),
contentDescription = video.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
Text(
text = formatDuration(video.durationMs),
modifier = Modifier.align(Alignment.BottomStart).padding(4.dp),
color = Color.White,
style = MaterialTheme.typography.labelSmall,
)
if (selected) {
Box(
Modifier.align(Alignment.TopEnd).padding(7.dp).size(26.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape),
contentAlignment = Alignment.Center,
) { Text("✓", color = Color.White, style = MaterialTheme.typography.labelLarge) }
}
}
}
private fun formatDuration(milliseconds: Long): String {
val totalSeconds = milliseconds.coerceAtLeast(0) / 1000
return "%d:%02d".format(totalSeconds / 60, totalSeconds % 60)
}
@@ -0,0 +1,66 @@
package com.uuard.shortformvideo.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.Font
import com.uuard.shortformvideo.R
object TikTokColors {
val Red = Color(0xFFFE2C55)
val Cyan = Color(0xFF25F4EE)
val TextPrimary = Color(0xFF161823)
val TextSecondary = Color(0x80161823)
val LightGray = Color(0xFFF1F1F2)
val Divider = Color(0xFFE3E3E4)
val RedDisabled = Color(0xFFFBB8C5)
val VideoBlack = Color(0xFF000000)
}
private val LightColors = lightColorScheme(
primary = TikTokColors.Red,
onPrimary = Color.White,
secondary = TikTokColors.TextPrimary,
onSecondary = Color.White,
background = Color.White,
onBackground = TikTokColors.TextPrimary,
surface = Color.White,
onSurface = TikTokColors.TextPrimary,
surfaceVariant = TikTokColors.LightGray,
onSurfaceVariant = TikTokColors.TextSecondary,
secondaryContainer = TikTokColors.LightGray,
onSecondaryContainer = TikTokColors.TextPrimary,
outline = TikTokColors.Divider,
outlineVariant = TikTokColors.Divider,
error = Color(0xFFB3261E),
)
private val AppFontFamily = FontFamily(Font(R.font.tiktok_sans))
private val TikTokTypography = Typography().let { defaults ->
Typography(
displayLarge = defaults.displayLarge.copy(fontFamily = AppFontFamily),
displayMedium = defaults.displayMedium.copy(fontFamily = AppFontFamily),
displaySmall = defaults.displaySmall.copy(fontFamily = AppFontFamily),
headlineLarge = defaults.headlineLarge.copy(fontFamily = AppFontFamily),
headlineMedium = defaults.headlineMedium.copy(fontFamily = AppFontFamily),
headlineSmall = defaults.headlineSmall.copy(fontFamily = AppFontFamily),
titleLarge = defaults.titleLarge.copy(fontFamily = AppFontFamily),
titleMedium = defaults.titleMedium.copy(fontFamily = AppFontFamily),
titleSmall = defaults.titleSmall.copy(fontFamily = AppFontFamily),
bodyLarge = defaults.bodyLarge.copy(fontFamily = AppFontFamily),
bodyMedium = defaults.bodyMedium.copy(fontFamily = AppFontFamily),
bodySmall = defaults.bodySmall.copy(fontFamily = AppFontFamily),
labelLarge = defaults.labelLarge.copy(fontFamily = AppFontFamily),
labelMedium = defaults.labelMedium.copy(fontFamily = AppFontFamily),
labelSmall = defaults.labelSmall.copy(fontFamily = AppFontFamily),
)
}
@Composable
fun TikTokTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = LightColors, typography = TikTokTypography, content = content)
}
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="27dp"
android:height="27dp"
android:autoMirrored="true"
android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M18,2H6A2,2 0,0 0,4 4V21a1,1 0,0 0,0.5 0.86,1 1,0 0,0 1,0L12,18.15l6.5,3.72A1,1 0,0 0,19 22a0.9,0.9 0,0 0,0.5 -0.14A1,1 0,0 0,20 21V4A2,2 0,0 0,18 2Z"
android:strokeAlpha="0.9"
android:fillColor="#ffffff"
android:fillAlpha="0.85"
/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M8.821,10L4.288,5.467C4.125,5.304 4.125,5.041 4.288,4.878L4.877,4.289C5.04,4.126 5.304,4.126 5.467,4.289L9.999,8.821L14.532,4.289C14.695,4.126 14.959,4.126 15.121,4.289L15.711,4.878C15.873,5.041 15.873,5.304 15.711,5.467L11.178,10L15.711,14.533C15.873,14.695 15.873,14.959 15.711,15.122L15.121,15.711C14.959,15.874 14.695,15.874 14.532,15.711L9.999,11.178L5.467,15.711C5.304,15.874 5.04,15.874 4.877,15.711L4.288,15.122C4.125,14.959 4.125,14.695 4.288,14.533L8.821,10Z"
android:fillColor="#151923"/>
</vector>
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:pathData="M41,14H7a2,2 0,0 1,0 -4H41A2,2 0,0 1,41 14Z"
android:fillColor="#6C707C"/>
<path
android:pathData="M41,26H7a2,2 0,0 1,0 -4H41A2,2 0,0 1,41 26Z"
android:fillColor="#6C707C"/>
<path
android:pathData="M41,38H7a2,2 0,0 1,0 -4H41A2,2 0,0 1,41 38Z"
android:fillColor="#6C707C"/>
</vector>
@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z"/>
</vector>
@@ -0,0 +1,7 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M6,5h4v14H6zM14,5h4v14h-4z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="14dp"
android:height="15dp"
android:viewportWidth="14"
android:viewportHeight="15">
<path
android:pathData="M3.167,1.738V11.262L11.01,6.5L3.167,1.738ZM1.75,1.108C1.75,0.28 2.656,-0.23 3.364,0.2L12.244,5.592C12.925,6.005 12.925,6.994 12.244,7.408L3.364,12.8C2.656,13.229 1.75,12.72 1.75,11.891V1.108Z"
android:fillColor="#ffffff"
android:fillAlpha="0.95"/>
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M7,7h10v3l4,-4 -4,-4v3H7c-2.21,0 -4,1.79 -4,4v4h2V9c0,-1.1 0.9,-2 2,-2zM17,17H7v-3l-4,4 4,4v-3h10c2.21,0 4,-1.79 4,-4v-4h-2v4c0,1.1 -0.9,2 -2,2zM13,9h-1l-2,1v1.5l1.5,-0.75V15H13z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="72dp"
android:height="72dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M8,6.82v10.36c0,0.79 0.87,1.27 1.54,0.84l8.14,-5.18c0.62,-0.39 0.62,-1.29 0,-1.69L9.54,5.98C8.87,5.55 8,6.03 8,6.82z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M16,3l2.29,2.29 -4.88,4.88 1.42,1.42 4.88,-4.88L22,9V3h-6zM4.27,5.68l5.91,5.91 1.41,-1.42 -5.9,-5.9L4.27,5.68zM18.29,18.29L16,21h6v-6l-2.29,2.29 -4.88,-4.88 -1.42,1.42 4.88,4.46zM4.27,18.32l1.42,1.41 5.9,-5.9 -1.41,-1.42 -5.91,5.91z"/>
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v2.21l2.45,2.45c0.03,-0.2 0.05,-0.41 0.05,-0.63zM19,12c0,0.94 -0.2,1.82 -0.54,2.64l1.51,1.51C20.63,14.91 21,13.5 21,12c0,-4.28 -2.99,-7.86 -7,-8.77v2.06c2.89,0.86 5,3.54 5,6.71zM4.27,3L3,4.27 7.73,9H3v6h4l5,5v-6.73L16.25,17c-0.67,0.52 -1.42,0.93 -2.25,1.18v2.06c1.38,-0.31 2.63,-0.95 3.69,-1.81L19.73,20 21,18.73 12,9.73 4.27,3zM12,4L9.91,6.09 12,8.18V4z"/>
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M3,9v6h4l5,5V4L7,9H3zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"/>
</vector>
Binary file not shown.
@@ -0,0 +1,93 @@
Copyright 2024 TikTok Inc. (https://github.com/tiktok/TikTokSans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.ShortFormVideo" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#6750A4</item>
<item name="android:navigationBarColor">#000000</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:statusBarColor">#000000</item>
</style>
</resources>
+6
View File
@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" apply false
}
+29
View File
@@ -0,0 +1,29 @@
name: short-form-video
services:
server:
build: ./server
ports:
- "8080:8080"
environment:
MEDIA_ROOT: /media
DATA_ROOT: /data
RESCAN_INTERVAL: 1m
API_TOKEN: ${API_TOKEN:?Set API_TOKEN in .env or the environment}
volumes:
- type: volume
source: catalog-data
target: /data
- type: bind
source: ${MEDIA_PATH:-./sample-media}
target: /media
read_only: true
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 3s
retries: 3
volumes:
catalog-data:
+4
View File
@@ -0,0 +1,4 @@
go 1.24.0
use ./server
+2
View File
@@ -0,0 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+1
View File
@@ -0,0 +1 @@
+3
View File
@@ -0,0 +1,3 @@
.git
*.db
short-form-video-server
+17
View File
@@ -0,0 +1,17 @@
FROM golang:1.24-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg wget \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --uid 1000 --create-home app
RUN mkdir -p /data /media && chown app:app /data
COPY --from=build /out/server /usr/local/bin/short-form-video-server
USER app
EXPOSE 8080
ENTRYPOINT ["short-form-video-server"]
+351
View File
@@ -0,0 +1,351 @@
package main
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/nicholas/short-form-video/server/internal/catalog"
"github.com/nicholas/short-form-video/server/internal/storage"
)
type server struct {
catalog *catalog.Catalog
dataRoot string
rescanning atomic.Bool
}
func main() {
mediaRoot := env("MEDIA_ROOT", "/media")
dataRoot := env("DATA_ROOT", "/data")
address := env("LISTEN_ADDRESS", ":8080")
token := os.Getenv("API_TOKEN")
if token == "" {
log.Fatal("API_TOKEN is required")
}
if err := os.MkdirAll(dataRoot, 0o755); err != nil {
log.Fatal(err)
}
store, err := storage.NewFilesystem(mediaRoot)
if err != nil {
log.Fatal(err)
}
cat, err := catalog.Open(filepath.Join(dataRoot, "catalog.db"), store)
if err != nil {
log.Fatal(err)
}
defer cat.Close()
s := &server{catalog: cat, dataRoot: dataRoot}
if count, err := cat.Rescan(context.Background()); err != nil {
log.Printf("initial rescan failed: %v", err)
} else {
log.Printf("indexed %d media files", count)
}
go s.periodicRescan(context.Background(), envDuration("RESCAN_INTERVAL", time.Minute))
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("GET /api/v1/videos", s.videos)
mux.HandleFunc("GET /api/v1/videos/{id}/content", s.content)
mux.HandleFunc("HEAD /api/v1/videos/{id}/content", s.content)
mux.HandleFunc("GET /api/v1/videos/{id}/thumbnail", s.thumbnail)
mux.HandleFunc("POST /api/v1/rescan", s.rescan)
mux.HandleFunc("GET /api/v1/collections", s.collections)
mux.HandleFunc("POST /api/v1/collections", s.createCollection)
mux.HandleFunc("DELETE /api/v1/collections/{id}", s.deleteCollection)
mux.HandleFunc("GET /api/v1/collections/{id}/videos", s.collectionVideos)
mux.HandleFunc("PUT /api/v1/collections/{id}/videos/{videoID}", s.addCollectionVideo)
mux.HandleFunc("DELETE /api/v1/collections/{id}/videos/{videoID}", s.removeCollectionVideo)
mux.HandleFunc("GET /api/v1/videos/{id}/collections", s.videoCollections)
log.Printf("listening on %s; media root %s", address, mediaRoot)
log.Fatal(http.ListenAndServe(address, logging(bearerAuth(token, mux))))
}
func bearerAuth(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
expected := "Bearer " + token
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte(expected)) != 1 {
w.Header().Set("WWW-Authenticate", "Bearer")
problem(w, http.StatusUnauthorized, errors.New("authentication required"))
return
}
next.ServeHTTP(w, r)
})
}
func (s *server) collections(w http.ResponseWriter, r *http.Request) {
items, err := s.catalog.Collections(r.Context())
if err != nil {
problem(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *server) createCollection(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
problem(w, http.StatusBadRequest, err)
return
}
item, err := s.catalog.CreateCollection(r.Context(), body.Name)
if err != nil {
problem(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusCreated, item)
}
func (s *server) deleteCollection(w http.ResponseWriter, r *http.Request) {
if err := s.catalog.DeleteCollection(r.Context(), r.PathValue("id")); err != nil {
if errors.Is(err, catalog.ErrManagedCollection) {
problem(w, http.StatusConflict, err)
} else {
problem(w, http.StatusNotFound, err)
}
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) collectionVideos(w http.ResponseWriter, r *http.Request) {
items, err := s.catalog.ListCollectionVideos(r.Context(), r.PathValue("id"))
if err != nil {
problem(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (s *server) addCollectionVideo(w http.ResponseWriter, r *http.Request) {
if err := s.catalog.SetCollectionVideo(r.Context(), r.PathValue("id"), r.PathValue("videoID"), true); err != nil {
problem(w, http.StatusBadRequest, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) removeCollectionVideo(w http.ResponseWriter, r *http.Request) {
if err := s.catalog.SetCollectionVideo(r.Context(), r.PathValue("id"), r.PathValue("videoID"), false); err != nil {
problem(w, http.StatusBadRequest, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) videoCollections(w http.ResponseWriter, r *http.Request) {
ids, err := s.catalog.CollectionIDsForVideo(r.Context(), r.PathValue("id"))
if err != nil {
problem(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"collection_ids": ids})
}
func (s *server) periodicRescan(ctx context.Context, interval time.Duration) {
if interval <= 0 {
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if !s.rescanning.CompareAndSwap(false, true) {
continue
}
count, err := s.catalog.Rescan(ctx)
s.rescanning.Store(false)
if err != nil {
log.Printf("periodic rescan failed: %v", err)
} else {
log.Printf("periodic rescan saw %d files", count)
}
}
}
}
func (s *server) videos(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit < 1 || limit > 100 {
limit = 40
}
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
items, err := s.catalog.ListVideos(r.Context(), limit, offset)
if err != nil {
problem(w, http.StatusInternalServerError, err)
return
}
next := ""
if len(items) == limit {
next = strconv.Itoa(offset + len(items))
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": next})
}
func (s *server) content(w http.ResponseWriter, r *http.Request) {
file, contentType, err := s.catalog.OpenVideo(r.Context(), r.PathValue("id"))
if err != nil {
problem(w, http.StatusNotFound, err)
return
}
defer file.Close()
info, err := file.(interface{ Stat() (os.FileInfo, error) }).Stat()
if err != nil {
problem(w, http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "private, max-age=3600")
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
}
func (s *server) thumbnail(w http.ResponseWriter, r *http.Request) {
file, _, err := s.catalog.OpenVideo(r.Context(), r.PathValue("id"))
if err != nil {
problem(w, http.StatusNotFound, err)
return
}
defer file.Close()
cacheDir := filepath.Join(s.dataRoot, "thumbnails")
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
problem(w, http.StatusInternalServerError, err)
return
}
cachePath := filepath.Join(cacheDir, r.PathValue("id")+".jpg")
image, err := os.ReadFile(cachePath)
if errors.Is(err, os.ErrNotExist) {
image, err = generateThumbnail(r.Context(), file, cacheDir, cachePath)
}
if err != nil {
problem(w, http.StatusUnprocessableEntity, fmt.Errorf("thumbnail unavailable: %w", err))
return
}
if len(image) == 0 {
problem(w, http.StatusUnprocessableEntity, errors.New("thumbnail generator produced an empty image"))
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Cache-Control", "private, max-age=86400")
w.Write(image)
}
func generateThumbnail(ctx context.Context, source io.ReadSeekCloser, cacheDir, cachePath string) ([]byte, error) {
input := ""
var inputTemp string
var extraFiles []*os.File
if local, ok := source.(*os.File); ok {
input = "/proc/self/fd/3"
extraFiles = []*os.File{local}
} else {
temp, err := os.CreateTemp(cacheDir, "source-*")
if err != nil {
return nil, err
}
inputTemp = temp.Name()
defer os.Remove(inputTemp)
if _, err := source.Seek(0, io.SeekStart); err != nil {
temp.Close()
return nil, err
}
if _, err := io.Copy(temp, source); err != nil {
temp.Close()
return nil, err
}
if err := temp.Close(); err != nil {
return nil, err
}
input = inputTemp
}
output, err := os.CreateTemp(cacheDir, "thumbnail-*.jpg")
if err != nil {
return nil, err
}
outputPath := output.Name()
if err := output.Close(); err != nil {
os.Remove(outputPath)
return nil, err
}
defer os.Remove(outputPath)
cmd := exec.CommandContext(ctx, "ffmpeg", "-v", "error", "-y", "-ss", "0.1", "-i", input, "-frames:v", "1", "-vf", "scale=480:-2", "-f", "image2", "-vcodec", "mjpeg", outputPath)
cmd.ExtraFiles = extraFiles
stderr, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("ffmpeg: %w: %s", err, strings.TrimSpace(string(stderr)))
}
image, err := os.ReadFile(outputPath)
if err != nil {
return nil, err
}
if len(image) == 0 {
return nil, errors.New("ffmpeg produced an empty image")
}
if err := os.Rename(outputPath, cachePath); err != nil {
return nil, err
}
return image, nil
}
func (s *server) rescan(w http.ResponseWriter, r *http.Request) {
if !s.rescanning.CompareAndSwap(false, true) {
problem(w, http.StatusConflict, errors.New("rescan already in progress"))
return
}
defer s.rescanning.Store(false)
count, err := s.catalog.Rescan(r.Context())
if err != nil {
problem(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"files_seen": count})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func problem(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, map[string]string{"error": err.Error()})
}
func env(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}
func envDuration(key string, fallback time.Duration) time.Duration {
value := os.Getenv(key)
if value == "" {
return fallback
}
duration, err := time.ParseDuration(value)
if err != nil {
log.Fatalf("invalid %s: %v", key, err)
}
return duration
}
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestBearerAuth(t *testing.T) {
handler := bearerAuth("secret", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
for _, test := range []struct {
path string
header string
status int
}{
{"/api/v1/videos", "", http.StatusUnauthorized},
{"/api/v1/videos", "Bearer wrong", http.StatusUnauthorized},
{"/api/v1/videos", "Bearer secret", http.StatusNoContent},
{"/healthz", "", http.StatusNoContent},
} {
request := httptest.NewRequest(http.MethodGet, test.path, nil)
request.Header.Set("Authorization", test.header)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != test.status {
t.Errorf("%s with %q: got %d, want %d", test.path, test.header, response.Code, test.status)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
module github.com/nicholas/short-form-video/server
go 1.24.0
require github.com/mattn/go-sqlite3 v1.14.24
+2
View File
@@ -0,0 +1,2 @@
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+492
View File
@@ -0,0 +1,492 @@
package catalog
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/nicholas/short-form-video/server/internal/storage"
)
type Catalog struct {
db *sql.DB
store storage.Store
}
type Video struct {
ID string `json:"id"`
Title string `json:"title"`
ContentType string `json:"content_type"`
DurationMS int64 `json:"duration_ms"`
Width int `json:"width"`
Height int `json:"height"`
PlaybackURL string `json:"playback_url"`
ThumbnailURL string `json:"thumbnail_url"`
}
type Collection struct {
ID string `json:"id"`
Name string `json:"name"`
VideoCount int `json:"video_count"`
CoverVideoID string `json:"cover_video_id,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Managed bool `json:"managed"`
SourcePath string `json:"source_path,omitempty"`
}
var ErrManagedCollection = errors.New("directory collections are managed by the server")
type Probe struct {
DurationMS int64
Width int
Height int
}
type ffprobeOutput struct {
Streams []struct {
CodecType string `json:"codec_type"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"streams"`
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
func Open(dbPath string, store storage.Store) (*Catalog, error) {
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on&_busy_timeout=5000&_journal_mode=WAL")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
c := &Catalog{db: db, store: store}
if err := c.migrate(); err != nil {
db.Close()
return nil, err
}
return c, nil
}
func (c *Catalog) Close() error { return c.db.Close() }
func (c *Catalog) migrate() error {
if _, err := c.db.Exec(`
CREATE TABLE IF NOT EXISTS scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE IF NOT EXISTS videos (
id TEXT PRIMARY KEY,
content_hash TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
content_type TEXT NOT NULL,
duration_ms INTEGER NOT NULL DEFAULT 0,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS video_objects (
object_key TEXT PRIMARY KEY,
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
size_bytes INTEGER NOT NULL,
modified_ns INTEGER NOT NULL,
last_seen_scan_id INTEGER NOT NULL REFERENCES scans(id)
);
CREATE INDEX IF NOT EXISTS idx_video_objects_video ON video_objects(video_id);
CREATE TABLE IF NOT EXISTS collections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS collection_videos (
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
video_id TEXT NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
added_at TEXT NOT NULL,
PRIMARY KEY(collection_id, video_id)
);`); err != nil {
return err
}
rows, err := c.db.Query(`PRAGMA table_info(collections)`)
if err != nil {
return err
}
hasSourcePath := false
for rows.Next() {
var cid, notNull, primaryKey int
var name, columnType string
var defaultValue any
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &primaryKey); err != nil {
rows.Close()
return err
}
hasSourcePath = hasSourcePath || name == "source_path"
}
rows.Close()
if !hasSourcePath {
if _, err := c.db.Exec(`ALTER TABLE collections ADD COLUMN source_path TEXT`); err != nil {
return err
}
}
_, err = c.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_collections_source_path ON collections(source_path) WHERE source_path IS NOT NULL`)
return err
}
func (c *Catalog) Collections(ctx context.Context) ([]Collection, error) {
rows, err := c.db.QueryContext(ctx, `SELECT c.id, c.name, COUNT(cv.video_id), COALESCE((SELECT cv2.video_id FROM collection_videos cv2 WHERE cv2.collection_id=c.id ORDER BY cv2.added_at DESC, cv2.video_id LIMIT 1), ''), c.source_path IS NOT NULL, COALESCE(c.source_path, '') FROM collections c LEFT JOIN collection_videos cv ON cv.collection_id=c.id GROUP BY c.id ORDER BY c.source_path IS NULL, COALESCE(c.source_path, c.name)`)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]Collection, 0)
for rows.Next() {
var item Collection
if err := rows.Scan(&item.ID, &item.Name, &item.VideoCount, &item.CoverVideoID, &item.Managed, &item.SourcePath); err != nil {
return nil, err
}
if item.CoverVideoID != "" {
item.CoverURL = "/api/v1/videos/" + item.CoverVideoID + "/thumbnail"
}
result = append(result, item)
}
return result, rows.Err()
}
func (c *Catalog) CreateCollection(ctx context.Context, name string) (Collection, error) {
name = strings.TrimSpace(name)
if name == "" {
return Collection{}, errors.New("collection name is required")
}
bytes := make([]byte, 12)
if _, err := rand.Read(bytes); err != nil {
return Collection{}, err
}
item := Collection{ID: hex.EncodeToString(bytes), Name: name}
_, err := c.db.ExecContext(ctx, `INSERT INTO collections(id,name,created_at) VALUES(?,?,?)`, item.ID, item.Name, time.Now().UTC().Format(time.RFC3339Nano))
return item, err
}
func (c *Catalog) DeleteCollection(ctx context.Context, id string) error {
var managed bool
if err := c.db.QueryRowContext(ctx, `SELECT source_path IS NOT NULL FROM collections WHERE id=?`, id).Scan(&managed); err != nil {
return err
}
if managed {
return ErrManagedCollection
}
result, err := c.db.ExecContext(ctx, `DELETE FROM collections WHERE id=?`, id)
if err != nil {
return err
}
if n, _ := result.RowsAffected(); n == 0 {
return sql.ErrNoRows
}
return nil
}
func (c *Catalog) SetCollectionVideo(ctx context.Context, collectionID, videoID string, included bool) error {
var managed bool
if err := c.db.QueryRowContext(ctx, `SELECT source_path IS NOT NULL FROM collections WHERE id=?`, collectionID).Scan(&managed); err != nil {
return err
}
if managed {
return ErrManagedCollection
}
if included {
_, err := c.db.ExecContext(ctx, `INSERT INTO collection_videos(collection_id,video_id,added_at) VALUES(?,?,?) ON CONFLICT DO NOTHING`, collectionID, videoID, time.Now().UTC().Format(time.RFC3339Nano))
return err
}
_, err := c.db.ExecContext(ctx, `DELETE FROM collection_videos WHERE collection_id=? AND video_id=?`, collectionID, videoID)
return err
}
func (c *Catalog) CollectionIDsForVideo(ctx context.Context, videoID string) ([]string, error) {
rows, err := c.db.QueryContext(ctx, `SELECT collection_id FROM collection_videos WHERE video_id=?`, videoID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]string, 0)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
result = append(result, id)
}
return result, rows.Err()
}
func (c *Catalog) ListCollectionVideos(ctx context.Context, collectionID string) ([]Video, error) {
rows, err := c.db.QueryContext(ctx, `SELECT DISTINCT v.id,v.title,v.content_type,v.duration_ms,v.width,v.height FROM videos v JOIN collection_videos cv ON cv.video_id=v.id JOIN video_objects o ON o.video_id=v.id WHERE cv.collection_id=? AND o.last_seen_scan_id=(SELECT MAX(id) FROM scans WHERE completed_at IS NOT NULL) ORDER BY cv.added_at DESC, v.id`, collectionID)
if err != nil {
return nil, err
}
defer rows.Close()
videos := make([]Video, 0)
for rows.Next() {
var v Video
if err := rows.Scan(&v.ID, &v.Title, &v.ContentType, &v.DurationMS, &v.Width, &v.Height); err != nil {
return nil, err
}
v.PlaybackURL = "/api/v1/videos/" + v.ID + "/content"
v.ThumbnailURL = "/api/v1/videos/" + v.ID + "/thumbnail"
videos = append(videos, v)
}
return videos, rows.Err()
}
func (c *Catalog) Rescan(ctx context.Context) (int, error) {
tx, err := c.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `INSERT INTO scans(started_at) VALUES(?)`, time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
return 0, err
}
scanID, _ := result.LastInsertId()
count := 0
directoryVideos := make(map[string]map[string]struct{})
err = c.store.Walk(ctx, func(object storage.Object) error {
count++
var videoID string
var size, modified int64
err := tx.QueryRowContext(ctx, `SELECT video_id, size_bytes, modified_ns FROM video_objects WHERE object_key=?`, object.Key).Scan(&videoID, &size, &modified)
if err == nil && size == object.Size && modified == object.ModTime {
_, err = tx.ExecContext(ctx, `UPDATE video_objects SET last_seen_scan_id=? WHERE object_key=?`, scanID, object.Key)
addDirectoryVideo(directoryVideos, object.Key, videoID)
return err
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
file, err := c.store.Open(ctx, object.Key)
if err != nil {
return err
}
hasher := sha256.New()
_, copyErr := io.Copy(hasher, file)
file.Close()
if copyErr != nil {
return copyErr
}
hash := hex.EncodeToString(hasher.Sum(nil))
contentType := contentTypeFor(object.Key)
probe := probeFile(ctx, object.Key, c.store)
videoID = hash[:32]
_, err = tx.ExecContext(ctx, `
INSERT INTO videos(id, content_hash, title, content_type, duration_ms, width, height, created_at)
VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(content_hash) DO NOTHING`, videoID, hash, titleFor(object.Key), contentType, probe.DurationMS, probe.Width, probe.Height, time.Now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
if err := tx.QueryRowContext(ctx, `SELECT id FROM videos WHERE content_hash=?`, hash).Scan(&videoID); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO video_objects(object_key, video_id, size_bytes, modified_ns, last_seen_scan_id)
VALUES(?,?,?,?,?)
ON CONFLICT(object_key) DO UPDATE SET video_id=excluded.video_id, size_bytes=excluded.size_bytes,
modified_ns=excluded.modified_ns, last_seen_scan_id=excluded.last_seen_scan_id`, object.Key, videoID, object.Size, object.ModTime, scanID)
addDirectoryVideo(directoryVideos, object.Key, videoID)
return err
})
if err != nil {
return 0, err
}
if err := syncDirectoryCollections(ctx, tx, directoryVideos); err != nil {
return 0, err
}
_, err = tx.ExecContext(ctx, `UPDATE scans SET completed_at=? WHERE id=?`, time.Now().UTC().Format(time.RFC3339Nano), scanID)
if err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return count, nil
}
func addDirectoryVideo(collections map[string]map[string]struct{}, objectKey, videoID string) {
directory := filepath.ToSlash(filepath.Dir(objectKey))
if directory == "." {
return
}
if collections[directory] == nil {
collections[directory] = make(map[string]struct{})
}
collections[directory][videoID] = struct{}{}
}
func syncDirectoryCollections(ctx context.Context, tx *sql.Tx, directories map[string]map[string]struct{}) error {
rows, err := tx.QueryContext(ctx, `SELECT id, source_path FROM collections WHERE source_path IS NOT NULL`)
if err != nil {
return err
}
var obsolete []string
for rows.Next() {
var id, path string
if err := rows.Scan(&id, &path); err != nil {
rows.Close()
return err
}
if _, present := directories[path]; !present {
obsolete = append(obsolete, id)
}
}
if err := rows.Close(); err != nil {
return err
}
for _, id := range obsolete {
if _, err := tx.ExecContext(ctx, `DELETE FROM collections WHERE id=?`, id); err != nil {
return err
}
}
paths := make([]string, 0, len(directories))
for path := range directories {
paths = append(paths, path)
}
sort.Strings(paths)
now := time.Now().UTC().Format(time.RFC3339Nano)
for _, path := range paths {
hash := sha256.Sum256([]byte("directory:" + path))
collectionID := hex.EncodeToString(hash[:12])
if _, err := tx.ExecContext(ctx, `INSERT INTO collections(id,name,created_at,source_path) VALUES(?,?,?,?) ON CONFLICT DO NOTHING`, collectionID, filepath.Base(path), now, path); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE collections SET name=? WHERE source_path=?`, filepath.Base(path), path); err != nil {
return err
}
rows, err := tx.QueryContext(ctx, `SELECT video_id FROM collection_videos WHERE collection_id=?`, collectionID)
if err != nil {
return err
}
var removed []string
for rows.Next() {
var videoID string
if err := rows.Scan(&videoID); err != nil {
rows.Close()
return err
}
if _, present := directories[path][videoID]; !present {
removed = append(removed, videoID)
}
}
if err := rows.Close(); err != nil {
return err
}
for _, videoID := range removed {
if _, err := tx.ExecContext(ctx, `DELETE FROM collection_videos WHERE collection_id=? AND video_id=?`, collectionID, videoID); err != nil {
return err
}
}
for videoID := range directories[path] {
if _, err := tx.ExecContext(ctx, `INSERT INTO collection_videos(collection_id,video_id,added_at) VALUES(?,?,?) ON CONFLICT DO NOTHING`, collectionID, videoID, now); err != nil {
return err
}
}
}
return nil
}
func probeFile(ctx context.Context, key string, store storage.Store) Probe {
file, err := store.Open(ctx, key)
if err != nil {
return Probe{}
}
defer file.Close()
cmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json", "pipe:0")
cmd.Stdin = file
output, err := cmd.Output()
if err != nil {
return Probe{}
}
var data ffprobeOutput
if json.Unmarshal(output, &data) != nil {
return Probe{}
}
var probe Probe
for _, stream := range data.Streams {
if stream.CodecType == "video" {
probe.Width, probe.Height = stream.Width, stream.Height
break
}
}
var seconds float64
if _, err := fmt.Sscanf(data.Format.Duration, "%f", &seconds); err == nil {
probe.DurationMS = int64(seconds * 1000)
}
return probe
}
func contentTypeFor(key string) string {
if strings.EqualFold(filepath.Ext(key), ".webm") {
return "video/webm"
}
return "video/mp4"
}
func titleFor(key string) string {
base := filepath.Base(key)
return strings.TrimSuffix(base, filepath.Ext(base))
}
func (c *Catalog) ListVideos(ctx context.Context, limit, offset int) ([]Video, error) {
if limit < 1 || limit > 100 {
limit = 40
}
query := `SELECT DISTINCT v.id, v.title, v.content_type, v.duration_ms, v.width, v.height
FROM videos v JOIN video_objects o ON o.video_id=v.id
WHERE o.last_seen_scan_id=(SELECT MAX(id) FROM scans WHERE completed_at IS NOT NULL)`
query += ` ORDER BY v.created_at DESC, v.id LIMIT ? OFFSET ?`
rows, err := c.db.QueryContext(ctx, query, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
videos := make([]Video, 0)
for rows.Next() {
var v Video
if err := rows.Scan(&v.ID, &v.Title, &v.ContentType, &v.DurationMS, &v.Width, &v.Height); err != nil {
return nil, err
}
v.PlaybackURL = "/api/v1/videos/" + v.ID + "/content"
v.ThumbnailURL = "/api/v1/videos/" + v.ID + "/thumbnail"
videos = append(videos, v)
}
return videos, rows.Err()
}
func (c *Catalog) ObjectKey(ctx context.Context, videoID string) (string, error) {
var key string
err := c.db.QueryRowContext(ctx, `SELECT object_key FROM video_objects WHERE video_id=?
AND last_seen_scan_id=(SELECT MAX(id) FROM scans WHERE completed_at IS NOT NULL)
ORDER BY object_key LIMIT 1`, videoID).Scan(&key)
return key, err
}
func (c *Catalog) OpenVideo(ctx context.Context, videoID string) (io.ReadSeekCloser, string, error) {
key, err := c.ObjectKey(ctx, videoID)
if err != nil {
return nil, "", err
}
file, err := c.store.Open(ctx, key)
return file, contentTypeFor(key), err
}
+89
View File
@@ -0,0 +1,89 @@
package catalog
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/nicholas/short-form-video/server/internal/storage"
)
func TestRescanPreservesIdentityAfterMove(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
oldPath := filepath.Join(root, "old.mp4")
if err := os.WriteFile(oldPath, []byte("same video bytes"), 0o644); err != nil {
t.Fatal(err)
}
store, _ := storage.NewFilesystem(root)
cat, err := Open(filepath.Join(t.TempDir(), "catalog.db"), store)
if err != nil {
t.Fatal(err)
}
defer cat.Close()
if _, err := cat.Rescan(ctx); err != nil {
t.Fatal(err)
}
videos, err := cat.ListVideos(ctx, 40, 0)
if err != nil || len(videos) != 1 {
t.Fatalf("videos=%v err=%v", videos, err)
}
id := videos[0].ID
if err := os.Mkdir(filepath.Join(root, "moved"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Rename(oldPath, filepath.Join(root, "moved", "renamed.mp4")); err != nil {
t.Fatal(err)
}
if _, err := cat.Rescan(ctx); err != nil {
t.Fatal(err)
}
videos, err = cat.ListVideos(ctx, 40, 0)
if err != nil || len(videos) != 1 {
t.Fatalf("videos=%v err=%v", videos, err)
}
if videos[0].ID != id {
t.Fatalf("identity not preserved: %+v", videos[0])
}
key, err := cat.ObjectKey(ctx, id)
if err != nil || key != "moved/renamed.mp4" {
t.Fatalf("key=%q err=%v", key, err)
}
collections, err := cat.Collections(ctx)
if err != nil || len(collections) != 1 || collections[0].Name != "moved" || !collections[0].Managed || collections[0].VideoCount != 1 {
t.Fatalf("collections=%v err=%v", collections, err)
}
if err := cat.SetCollectionVideo(ctx, collections[0].ID, id, false); !errors.Is(err, ErrManagedCollection) {
t.Fatalf("managed collection mutation err=%v", err)
}
if err := os.WriteFile(filepath.Join(root, "moved", "copy.webm"), []byte("same video bytes"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := cat.Rescan(ctx); err != nil {
t.Fatal(err)
}
collections, err = cat.Collections(ctx)
if err != nil || len(collections) != 1 || collections[0].VideoCount != 1 {
t.Fatalf("deduplicated collections=%v err=%v", collections, err)
}
collectionVideos, err := cat.ListCollectionVideos(ctx, collections[0].ID)
if err != nil || len(collectionVideos) != 1 {
t.Fatalf("collection videos=%v err=%v", collectionVideos, err)
}
var addedAt string
if err := cat.db.QueryRow(`SELECT added_at FROM collection_videos WHERE collection_id=? AND video_id=?`, collections[0].ID, id).Scan(&addedAt); err != nil {
t.Fatal(err)
}
if _, err := cat.Rescan(ctx); err != nil {
t.Fatal(err)
}
var rescannedAddedAt string
if err := cat.db.QueryRow(`SELECT added_at FROM collection_videos WHERE collection_id=? AND video_id=?`, collections[0].ID, id).Scan(&rescannedAddedAt); err != nil {
t.Fatal(err)
}
if rescannedAddedAt != addedAt {
t.Fatalf("membership rewritten: %q != %q", rescannedAddedAt, addedAt)
}
}
+63
View File
@@ -0,0 +1,63 @@
package storage
import (
"context"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
)
type Filesystem struct {
root string
}
func NewFilesystem(root string) (*Filesystem, error) {
abs, err := filepath.Abs(root)
if err != nil {
return nil, err
}
return &Filesystem{root: abs}, nil
}
func (s *Filesystem) Walk(ctx context.Context, visit func(Object) error) error {
return filepath.WalkDir(s.root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if err := ctx.Err(); err != nil {
return err
}
if entry.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(entry.Name()))
if ext != ".mp4" && ext != ".webm" {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
key, err := filepath.Rel(s.root, path)
if err != nil {
return err
}
return visit(Object{Key: filepath.ToSlash(key), Size: info.Size(), ModTime: info.ModTime().UnixNano()})
})
}
func (s *Filesystem) Open(_ context.Context, key string) (io.ReadSeekCloser, error) {
clean := filepath.Clean(filepath.FromSlash(key))
if clean == "." || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("invalid object key")
}
path := filepath.Join(s.root, clean)
rel, err := filepath.Rel(s.root, path)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return nil, fmt.Errorf("object key escapes storage root")
}
return os.Open(path)
}
@@ -0,0 +1,40 @@
package storage
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestFilesystemRejectsTraversal(t *testing.T) {
store, err := NewFilesystem(t.TempDir())
if err != nil {
t.Fatal(err)
}
for _, key := range []string{"../secret", "/etc/passwd", "a/../../secret"} {
if _, err := store.Open(context.Background(), key); err == nil {
t.Errorf("Open(%q) succeeded", key)
}
}
}
func TestFilesystemWalksSupportedFiles(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "nested"), 0o755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"nested/a.mp4", "b.WEBM", "ignore.txt"} {
if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
store, _ := NewFilesystem(root)
var keys []string
if err := store.Walk(context.Background(), func(object Object) error { keys = append(keys, object.Key); return nil }); err != nil {
t.Fatal(err)
}
if len(keys) != 2 {
t.Fatalf("got %v", keys)
}
}
+17
View File
@@ -0,0 +1,17 @@
package storage
import (
"context"
"io"
)
type Object struct {
Key string
Size int64
ModTime int64
}
type Store interface {
Walk(context.Context, func(Object) error) error
Open(context.Context, string) (io.ReadSeekCloser, error)
}
+17
View File
@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "short-form-video"
include(":android:app")