Remove embedded credentials and private deployment configuration; document the remaining publication checks.
This commit is contained in:
commit
2dfa4e7563
54 files changed
+3579
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
@@ -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=
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.git
|
||||
*.db
|
||||
short-form-video-server
|
||||
@@ -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"]
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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, ¬Null, &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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user