From d0bff7bb9e635388474383580a7ce97c041de9e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 16:53:03 +0000 Subject: [PATCH 1/6] Chromecast, PiP button, title watched, custom lists, skip-intro fix Casting - media3-cast CastPlayer in PlaybackSession (Default Media Receiver). Connecting moves the current item to the TV at the phone's position; disconnecting brings it back paused. Progress, mini player, up next, sleep timer, TV subtitles and volume keys work while casting; a session that survives an app restart is re-adopted from the item's extras. - CastMediaProxy: a LAN HTTP server the receiver streams through. It replays requests with the stream's headers (via the download cache, so downloads cast), rewrites HLS playlists, strips fake PNG segment prefixes, adds CORS, and serves subtitles as WebVTT. - Undocumented behaviour relied on: the Default Media Receiver loading http:// media from a LAN address; networks with client isolation block it. Player - Picture-in-picture button in the top bar. - Subtitle parsing moved to data/subtitles so the proxy can convert to WebVTT. - ImagePrefixStrippingDataSource moved to data/streaming (shared with the proxy). Skip intro - MAL id and episode now come from AnimeEpisodeMapper (ani.zip + AniList), fixing split cours, arc-based TMDB seasons and titles whose search ranks shorts first (Frieren). Title search remains a fallback and prefers TV over ONA. The submission timed on the closest file length wins. AniSkip 404 means no times. Library - Mark a whole title watched/unwatched from Details. - Custom lists (Room v7 with a real 6->7 migration): Add to list sheet on Details, lists row on the Saved tab, list screen with rename/delete/remove+undo, included in backups. Docs: roadmap updated (DNS-for-playback item dropped), CLAUDE.md describes casting and lists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H8GM7PfnBT2a3jjqSK47hS --- CLAUDE.md | 24 +- app/build.gradle.kts | 1 + app/proguard-rules.pro | 4 +- app/src/main/AndroidManifest.xml | 5 + .../java/com/ivor/openstream/MainActivity.kt | 17 + .../openstream/data/backup/LibraryBackup.kt | 58 +- .../data/cast/CastMediaItemConverter.kt | 162 +++++ .../openstream/data/cast/CastMediaProxy.kt | 606 ++++++++++++++++++ .../data/cast/CastOptionsProvider.kt | 35 + .../ivor/openstream/data/local/AppDatabase.kt | 10 +- .../data/local/dao/CustomListDao.kt | 90 +++ .../data/local/entity/CustomListEntity.kt | 30 + .../data/repository/CustomListRepository.kt | 39 ++ .../data/repository/SkipTimesRepository.kt | 99 ++- .../ImagePrefixStrippingDataSource.kt | 2 +- .../data/subtitles/SubtitleParser.kt | 134 ++++ .../com/ivor/openstream/di/DatabaseModule.kt | 41 +- .../presentation/details/DetailsScreen.kt | 92 +++ .../presentation/details/DetailsViewModel.kt | 148 +++++ .../presentation/lists/CustomListScreen.kt | 226 +++++++ .../presentation/lists/CustomListViewModel.kt | 53 ++ .../presentation/lists/ListComponents.kt | 296 +++++++++ .../presentation/navigation/AppNavigation.kt | 20 + .../presentation/player/PictureInPicture.kt | 23 + .../presentation/player/PlayerScreen.kt | 63 +- .../presentation/player/PlayerViewModel.kt | 48 +- .../player/components/CastComponents.kt | 485 ++++++++++++++ .../player/components/ExoPlayerView.kt | 131 +--- .../player/components/PlayerControls.kt | 15 +- .../presentation/player/session/MiniPlayer.kt | 35 +- .../player/session/PlaybackSession.kt | 572 ++++++++++++++++- .../settings/SettingsViewModel.kt | 1 + .../watch_later/WatchLaterScreen.kt | 54 ++ .../watch_later/WatchLaterViewModel.kt | 15 +- docs/ROADMAP.md | 23 +- gradle/libs.versions.toml | 1 + 36 files changed, 3466 insertions(+), 192 deletions(-) create mode 100644 app/src/main/java/com/ivor/openstream/data/cast/CastMediaItemConverter.kt create mode 100644 app/src/main/java/com/ivor/openstream/data/cast/CastMediaProxy.kt create mode 100644 app/src/main/java/com/ivor/openstream/data/cast/CastOptionsProvider.kt create mode 100644 app/src/main/java/com/ivor/openstream/data/local/dao/CustomListDao.kt create mode 100644 app/src/main/java/com/ivor/openstream/data/local/entity/CustomListEntity.kt create mode 100644 app/src/main/java/com/ivor/openstream/data/repository/CustomListRepository.kt rename app/src/main/java/com/ivor/openstream/{presentation/player/session => data/streaming}/ImagePrefixStrippingDataSource.kt (98%) create mode 100644 app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleParser.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/lists/CustomListScreen.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/lists/CustomListViewModel.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/lists/ListComponents.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/player/components/CastComponents.kt diff --git a/CLAUDE.md b/CLAUDE.md index 517282c..4d99b14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,8 +41,9 @@ Single activity (`MainActivity`), Navigation Compose, Hilt everywhere. ``` data/remote TMDB (TmdbApi), GitHub releases, DTOs -data/local Room: watch later, downloads, watch progress, id mappings -data/repository Repository implementations (anime, downloads, progress, OpenSubtitles) +data/local Room: watch later, custom lists, downloads, watch progress, id mappings +data/repository Repository implementations (anime, downloads, progress, lists, OpenSubtitles) +data/cast Cast options, media item converter, LAN proxy the receiver streams through data/streaming Source resolution, extension -> provider registry, id mapping data/extensions Extension catalog: repos, cache, parser, ranking, bundled copy data/service Media3 download service @@ -55,7 +56,7 @@ Rules: - Composables render state and forward intent. ViewModels own screen state as `StateFlow`. Networking and persistence stay in `data/`. - Routes and arguments live in `presentation/navigation/AppNavigation.kt`. -- Room schema changes need a real `Migration` in `di/DatabaseModule.kt` (current version 6). +- Room schema changes need a real `Migration` in `di/DatabaseModule.kt` (current version 7). `fallbackToDestructiveMigration` is only a safety net; users' downloads and progress live there. ## How the main features work @@ -77,6 +78,13 @@ Rules: to it and never releases it; leaving the player keeps playback going in `MiniPlayer`. The session also records watch progress (`WatchProgressRepository`) and queues the next episode for Continue Watching. Debug builds log player events under `EventLogger`. +- **Casting.** `PlaybackSession` also owns a Media3 `CastPlayer` (Default Media Receiver, options in + `CastOptionsProvider`, initialised from `MainActivity`). `activePlayer` is the TV while casting; + connecting moves the item there at the phone's position, disconnecting brings it back paused. + Everything the receiver loads goes through `CastMediaProxy`, a small HTTP server on the phone: + stateless URLs carry the target and headers, HLS playlists are rewritten, segments are read through + the download cache and `ImagePrefixStrippingDataSource`, subtitles are served as WebVTT. The + phone must stay on the TV's network. `PlayerScreen` swaps `ExoPlayerView` for `CastPlaybackView`. - **Player UI.** Controls in `PlayerControls`; settings and sources share `PlayerPanelHost` (bottom sheet inline, in-player side panel in fullscreen so immersive mode survives). - **Subtitles.** `OpenSubtitlesRepository` (keyless legacy REST API) plus any the stream carries. @@ -86,8 +94,12 @@ Rules: Media3 playback/downloads and WebView sources still use the system resolver. - **Settings.** `AppSettingsStore` (SharedPreferences) holds theme, dynamic color, DNS and Wi-Fi-only downloads; `MainActivity` applies the theme. -- **Skip intro.** `SkipTimesRepository`: AniList GraphQL finds the MAL id, AniSkip v2 gives the - intro/recap/credits times (anime only). Both are keyless public APIs with no stability promise. +- **Skip intro.** `SkipTimesRepository`: `AnimeEpisodeMapper` gives the MAL id and the episode within + that entry (AniList title search only as a fallback), AniSkip v2 gives the intro/recap/credits + times (anime only), and the player picks the submission timed on the closest file length. All + keyless public APIs with no stability promise. +- **Lists.** Watch Later plus user lists (`CustomListRepository`, `custom_lists` tables); Details has + "Add to list" and "Mark all watched", the Saved tab shows the lists. - **Backup / diagnostics.** `LibraryBackup` (JSON, merge on restore) and `Diagnostics` (crash files in `filesDir/crashes`, recorder installed in `OpenStreamApp`) back the Settings entries. - **Deep links.** `MainActivity` is `singleTask`; `DeepLinks` turns TMDB links (VIEW or shared text) @@ -104,6 +116,8 @@ Rules: - Several Vidking routes currently 404/500 for most titles; Yoru (`cdn`) is the reliable one. - AniList (about 90 requests a minute) and AniSkip are unauthenticated and undocumented as a contract; skip buttons simply don't appear when they fail. +- Casting relies on the receiver being allowed to load `http://` media from the phone's LAN address + (the Default Media Receiver page is HTTPS). Networks with client isolation block it. - Wyzie subtitles now require an API key and were removed. Don't add features that need users to supply API keys. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e763521..9329c61 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -152,6 +152,7 @@ dependencies { implementation(libs.androidx.media3.exoplayer.hls) implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.ui) + implementation(libs.androidx.media3.cast) // Graphics Shapes implementation(libs.androidx.graphics.shapes) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..ad4c6fa 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,6 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile +# Named only in the manifest (Cast framework options), so R8 can't see it's used. +-keep class com.ivor.openstream.data.cast.CastOptionsProvider { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 91601bf..bf5f93c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,6 +68,11 @@ + + + (null) private var deepLinkRequest by mutableStateOf(null) @@ -50,6 +54,7 @@ class MainActivity : ComponentActivity() { ) } + playbackSession.initCast() AppShortcut.publish(this) // Only a fresh launch; a recreated activity already acted on its shortcut. if (savedInstanceState == null) handleLaunchIntent(intent) @@ -92,4 +97,16 @@ class MainActivity : ComponentActivity() { Toast.makeText(this, "OpenStream opens TMDB movie and TV links", Toast.LENGTH_LONG).show() } } + + /** While casting, the volume keys change the TV's volume rather than the phone's. */ + override fun dispatchKeyEvent(event: android.view.KeyEvent): Boolean { + val up = when (event.keyCode) { + android.view.KeyEvent.KEYCODE_VOLUME_UP -> true + android.view.KeyEvent.KEYCODE_VOLUME_DOWN -> false + else -> return super.dispatchKeyEvent(event) + } + if (!playbackSession.castStatus.value.isCasting) return super.dispatchKeyEvent(event) + if (event.action == android.view.KeyEvent.ACTION_DOWN) playbackSession.adjustCastVolume(up) + return true + } } diff --git a/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt b/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt index f808834..2398905 100644 --- a/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt +++ b/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt @@ -1,8 +1,11 @@ package com.ivor.openstream.data.backup +import com.ivor.openstream.data.local.dao.CustomListDao import com.ivor.openstream.data.local.dao.HiddenTitleDao import com.ivor.openstream.data.local.dao.WatchLaterDao import com.ivor.openstream.data.local.dao.WatchProgressDao +import com.ivor.openstream.data.local.entity.CustomListEntity +import com.ivor.openstream.data.local.entity.CustomListItemEntity import com.ivor.openstream.data.local.entity.HiddenTitleEntity import com.ivor.openstream.data.local.entity.WatchLaterEntity import com.ivor.openstream.data.local.entity.WatchProgressEntity @@ -19,20 +22,28 @@ import javax.inject.Inject import javax.inject.Singleton /** What a restore added, for the confirmation message. */ -data class RestoreSummary(val watchLater: Int, val progress: Int, val hidden: Int, val settingsRestored: Boolean) +data class RestoreSummary( + val watchLater: Int, + val progress: Int, + val hidden: Int, + val settingsRestored: Boolean, + val lists: Int = 0 +) class BackupFormatException(message: String) : Exception(message) /** - * The user's library as one JSON file: Watch Later, watch progress (which is also History), hidden - * titles and app settings. Downloads aren't included; they're video files tied to this device. - * Restoring merges: nothing already on the device is deleted, and progress keeps the newer row. + * The user's library as one JSON file: Watch Later, custom lists, watch progress (which is also + * History), hidden titles and app settings. Downloads aren't included; they're video files tied to this device. + * Restoring merges: nothing already on the device is deleted, progress keeps the newer row, and + * lists merge by name. */ @Singleton class LibraryBackup @Inject constructor( private val watchLaterDao: WatchLaterDao, private val watchProgressDao: WatchProgressDao, private val hiddenTitleDao: HiddenTitleDao, + private val customListDao: CustomListDao, private val settingsStore: AppSettingsStore ) { private val json = Json { @@ -47,6 +58,15 @@ class LibraryBackup @Inject constructor( watchLater = watchLaterDao.getAllWatchLaterItems().first().map { it.toBackup() }, progress = watchProgressDao.observeAll().first().map { it.toBackup() }, hidden = hiddenTitleDao.observeAll().first().map { it.toBackup() }, + lists = customListDao.allItems().groupBy { it.listId }.let { itemsByList -> + customListDao.allLists().map { list -> + BackupList( + name = list.name, + createdAt = list.createdAt, + items = itemsByList[list.id].orEmpty().map { it.toBackup() } + ) + } + }, settings = settingsStore.current.toBackup() ) output.bufferedWriter().use { it.write(json.encodeToString(BackupFile.serializer(), backup)) } @@ -69,13 +89,19 @@ class LibraryBackup @Inject constructor( } } backup.hidden.forEach { hiddenTitleDao.insert(it.toEntity()) } + backup.lists.forEach { list -> + val listId = customListDao.findByName(list.name) + ?: customListDao.insertList(CustomListEntity(name = list.name, createdAt = list.createdAt)) + list.items.forEach { customListDao.addItem(it.toEntity(listId)) } + } backup.settings?.let { saved -> settingsStore.update { saved.applyTo(it) } } return RestoreSummary( watchLater = backup.watchLater.size, progress = progressAdded, hidden = backup.hidden.size, - settingsRestored = backup.settings != null + settingsRestored = backup.settings != null, + lists = backup.lists.size ) } @@ -94,9 +120,31 @@ private data class BackupFile( val watchLater: List = emptyList(), val progress: List = emptyList(), val hidden: List = emptyList(), + val lists: List = emptyList(), val settings: BackupSettings? = null ) +@Serializable +private data class BackupList( + val name: String, + val createdAt: Long = 0L, + val items: List = emptyList() +) + +@Serializable +private data class BackupListItem( + val tmdbId: Int, + val mediaType: String, + val title: String, + val posterPath: String? = null, + val voteAverage: Double = 0.0, + val addedAt: Long = 0L +) { + fun toEntity(listId: Long) = CustomListItemEntity(listId, tmdbId, mediaType, title, posterPath, voteAverage, addedAt) +} + +private fun CustomListItemEntity.toBackup() = BackupListItem(tmdbId, mediaType, title, posterPath, voteAverage, addedAt) + @Serializable private data class BackupWatchLater( val id: Int, diff --git a/app/src/main/java/com/ivor/openstream/data/cast/CastMediaItemConverter.kt b/app/src/main/java/com/ivor/openstream/data/cast/CastMediaItemConverter.kt new file mode 100644 index 0000000..5aa833e --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/cast/CastMediaItemConverter.kt @@ -0,0 +1,162 @@ +package com.ivor.openstream.data.cast + +import android.net.Uri +import android.os.Bundle +import androidx.media3.cast.MediaItemConverter +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import com.google.android.gms.cast.MediaInfo +import com.google.android.gms.cast.MediaQueueItem +import com.google.android.gms.cast.MediaTrack +import com.google.android.gms.common.images.WebImage +import org.json.JSONArray +import org.json.JSONObject +import com.google.android.gms.cast.MediaMetadata as CastMetadata + +/** + * Turns the app's media items into what the Default Media Receiver loads, and back. + * + * Unlike Media3's default converter this sends subtitle configurations as WebVTT text tracks (the + * one marked [C.SELECTION_FLAG_DEFAULT] starts active) and keeps the item's extras, which carry what + * is playing, so a session the app rejoins after a restart still knows its title and episode. + */ +class CastMediaItemConverter : MediaItemConverter { + + override fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem { + val configuration = requireNotNull(mediaItem.localConfiguration) { "Cast items need a URI" } + val metadata = mediaItem.mediaMetadata + val isEpisode = metadata.extras?.getBoolean(EXTRA_IS_EPISODE) == true + + val castMetadata = CastMetadata(if (isEpisode) CastMetadata.MEDIA_TYPE_TV_SHOW else CastMetadata.MEDIA_TYPE_MOVIE) + metadata.title?.let { castMetadata.putString(CastMetadata.KEY_TITLE, it.toString()) } + metadata.subtitle?.let { castMetadata.putString(CastMetadata.KEY_SUBTITLE, it.toString()) } + metadata.albumTitle?.let { castMetadata.putString(CastMetadata.KEY_SERIES_TITLE, it.toString()) } + metadata.artworkUri?.let { castMetadata.addImage(WebImage(it)) } + + val tracks = configuration.subtitleConfigurations.mapIndexed { index, subtitle -> + MediaTrack.Builder(index + 1L, MediaTrack.TYPE_TEXT) + .setSubtype(MediaTrack.SUBTYPE_SUBTITLES) + .setContentId(subtitle.uri.toString()) + .setContentType("text/vtt") + .setName(subtitle.label) + .setLanguage(subtitle.language ?: "und") + .build() + } + val activeTrackIds = configuration.subtitleConfigurations + .withIndex() + .filter { (_, subtitle) -> subtitle.selectionFlags and C.SELECTION_FLAG_DEFAULT != 0 } + .map { (index, _) -> index + 1L } + .take(1) + .toLongArray() + + val customData = JSONObject() + .put(KEY_MEDIA_ID, mediaItem.mediaId) + .put(KEY_URI, configuration.uri.toString()) + .put(KEY_MIME, configuration.mimeType) + .put(KEY_TITLE, metadata.title?.toString()) + .put(KEY_SUBTITLE, metadata.subtitle?.toString()) + .put(KEY_ALBUM, metadata.albumTitle?.toString()) + .put(KEY_ARTWORK, metadata.artworkUri?.toString()) + .put(KEY_EXTRAS, metadata.extras?.let(::bundleToJson)) + .put(KEY_TRACKS, JSONArray().apply { + configuration.subtitleConfigurations.forEach { subtitle -> + put( + JSONObject() + .put("uri", subtitle.uri.toString()) + .put("label", subtitle.label) + .put("language", subtitle.language) + .put("id", subtitle.id) + ) + } + }) + + val info = MediaInfo.Builder(configuration.uri.toString()) + .setContentUrl(configuration.uri.toString()) + .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) + .setContentType(configuration.mimeType ?: "video/mp4") + .setMetadata(castMetadata) + .setMediaTracks(tracks) + .setCustomData(customData) + .apply { + metadata.extras?.getString(EXTRA_HLS_VIDEO_FORMAT)?.let { setHlsVideoSegmentFormat(it) } + } + .build() + return MediaQueueItem.Builder(info) + .setActiveTrackIds(activeTrackIds) + .setCustomData(customData) + .build() + } + + override fun toMediaItem(mediaQueueItem: MediaQueueItem): MediaItem { + val info = mediaQueueItem.media + val data = info?.customData ?: mediaQueueItem.customData ?: JSONObject() + val uri = data.optStringOrNull(KEY_URI) ?: info?.contentUrl ?: info?.contentId ?: "" + val extras = data.optJSONObject(KEY_EXTRAS)?.let(::jsonToBundle) + val metadata = MediaMetadata.Builder() + .setTitle(data.optStringOrNull(KEY_TITLE) ?: info?.metadata?.getString(CastMetadata.KEY_TITLE)) + .setSubtitle(data.optStringOrNull(KEY_SUBTITLE)) + .setAlbumTitle(data.optStringOrNull(KEY_ALBUM)) + .setArtworkUri(data.optStringOrNull(KEY_ARTWORK)?.let(Uri::parse)) + .setExtras(extras) + .build() + val tracks = data.optJSONArray(KEY_TRACKS) + val subtitles = (0 until (tracks?.length() ?: 0)).mapNotNull { index -> + val track = tracks?.optJSONObject(index) ?: return@mapNotNull null + val trackUri = track.optStringOrNull("uri") ?: return@mapNotNull null + MediaItem.SubtitleConfiguration.Builder(Uri.parse(trackUri)) + .setMimeType("text/vtt") + .setLabel(track.optStringOrNull("label")) + .setLanguage(track.optStringOrNull("language")) + .setId(track.optStringOrNull("id")) + .build() + } + return MediaItem.Builder() + .setMediaId(data.optStringOrNull(KEY_MEDIA_ID) ?: MediaItem.DEFAULT_MEDIA_ID) + .setUri(uri) + .setMimeType(data.optStringOrNull(KEY_MIME) ?: info?.contentType) + .setMediaMetadata(metadata) + .setSubtitleConfigurations(subtitles) + .build() + } + + private fun bundleToJson(bundle: Bundle): JSONObject = JSONObject().apply { + bundle.keySet().forEach { key -> + when (val value = @Suppress("DEPRECATION") bundle.get(key)) { + is String, is Int, is Long, is Boolean, is Double -> put(key, value) + } + } + } + + private fun jsonToBundle(json: JSONObject): Bundle = Bundle().apply { + json.keys().forEach { key -> + when (val value = json.get(key)) { + is String -> putString(key, value) + is Int -> putInt(key, value) + is Long -> putLong(key, value) + is Boolean -> putBoolean(key, value) + is Double -> putDouble(key, value) + } + } + } + + private fun JSONObject.optStringOrNull(key: String): String? = + if (isNull(key)) null else optString(key).takeIf { it.isNotEmpty() } + + companion object { + /** Extras key: true for a TV episode (the receiver shows the series title). */ + const val EXTRA_IS_EPISODE = "openstream.is_episode" + /** Extras key: `fmp4` or `mpeg2_ts` for HLS streams, when known. */ + const val EXTRA_HLS_VIDEO_FORMAT = "openstream.hls_video_format" + + private const val KEY_MEDIA_ID = "mediaId" + private const val KEY_URI = "uri" + private const val KEY_MIME = "mimeType" + private const val KEY_TITLE = "title" + private const val KEY_SUBTITLE = "subtitle" + private const val KEY_ALBUM = "album" + private const val KEY_ARTWORK = "artwork" + private const val KEY_EXTRAS = "extras" + private const val KEY_TRACKS = "tracks" + } +} diff --git a/app/src/main/java/com/ivor/openstream/data/cast/CastMediaProxy.kt b/app/src/main/java/com/ivor/openstream/data/cast/CastMediaProxy.kt new file mode 100644 index 0000000..c30b2b1 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/cast/CastMediaProxy.kt @@ -0,0 +1,606 @@ +package com.ivor.openstream.data.cast + +import android.content.Context +import android.content.SharedPreferences +import android.net.Uri +import android.util.Base64 +import android.util.Log +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.HttpDataSource +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.ContentMetadata +import com.ivor.openstream.data.repository.OpenSubtitlesRepository +import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT +import com.ivor.openstream.data.streaming.ImagePrefixStrippingDataSource +import com.ivor.openstream.data.subtitles.isSubtitleAd +import com.ivor.openstream.data.subtitles.parseSubtitles +import com.ivor.openstream.data.subtitles.toWebVtt +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream +import java.io.FileNotFoundException +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.Inet4Address +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.NetworkInterface +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.security.SecureRandom +import java.util.Collections +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.zip.GZIPInputStream +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A small HTTP server on the phone that a Cast receiver streams through. + * + * A Chromecast fetches media itself, so it can't send the Referer/Origin headers most sources need, + * it needs CORS headers most stream CDNs don't send, it can't read the fake PNG prefix some anime + * CDNs put before TS segments, and it only shows WebVTT captions. So while casting, every URL handed + * to the receiver points here: requests are replayed upstream with the stream's headers (through the + * download cache, so downloaded episodes cast too), HLS playlists are rewritten so their segments, + * keys and renditions come back through the proxy, and subtitles are converted to WebVTT. + * + * URLs are stateless (`//m//`) so they keep working across a + * restart of the proxy; the random token keeps the server from being an open relay on the LAN. The + * phone has to stay on the same network as the receiver while casting. + */ +@OptIn(UnstableApi::class) +@Singleton +class CastMediaProxy @Inject constructor( + @ApplicationContext private val context: Context, + private val cache: Cache, + private val preferences: SharedPreferences +) { + private val executor: ExecutorService = Executors.newCachedThreadPool { runnable -> + Thread(runnable, "CastProxy").apply { isDaemon = true } + } + + @Volatile + private var server: ServerSocket? = null + + private val token: String by lazy { + preferences.getString(KEY_TOKEN, null) ?: ByteArray(12) + .also { SecureRandom().nextBytes(it) } + .joinToString("") { "%02x".format(it) } + .also { preferences.edit().putString(KEY_TOKEN, it).apply() } + } + + private val dataSourceFactory: DataSource.Factory by lazy { + val http = DefaultHttpDataSource.Factory() + .setUserAgent(BROWSER_USER_AGENT) + .setAllowCrossProtocolRedirects(true) + .setConnectTimeoutMs(15_000) + .setReadTimeoutMs(20_000) + val cached = CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(DefaultDataSource.Factory(context, http)) + .setCacheWriteDataSinkFactory(null) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + ImagePrefixStrippingDataSource.Factory(cached) + } + + private val subtitleCache: MutableMap = Collections.synchronizedMap( + object : LinkedHashMap(8, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?) = size > 8 + } + ) + + /** Starts the server if needed; returns its port, or null when no socket could be opened. */ + @Synchronized + fun start(): Int? { + server?.takeUnless { it.isClosed }?.let { return it.localPort } + val socket = runCatching { bind(preferences.getInt(KEY_PORT, DEFAULT_PORT)) } + .recoverCatching { bind(0) } + .onFailure { Log.e(TAG, "Could not open the cast proxy", it) } + .getOrNull() ?: return null + preferences.edit().putInt(KEY_PORT, socket.localPort).apply() + server = socket + executor.execute { acceptLoop(socket) } + Log.i(TAG, "Cast proxy listening on ${socket.localPort}") + return socket.localPort + } + + @Synchronized + fun stop() { + server?.let { runCatching { it.close() } } + server = null + subtitleCache.clear() + } + + /** + * The proxy URL a receiver at [receiver] should use for [target]. Null when the phone has no + * address on a local network or the server can't start. + */ + fun mediaUrl(target: String, headers: Map, receiver: InetAddress?): String? { + val base = baseUrl(receiver) ?: return null + return "$base/m/${encode(target, headers)}/${fileName(target, "media")}" + } + + /** Like [mediaUrl] for a subtitle file, served as WebVTT whatever its original format. */ + fun subtitleUrl(target: String, headers: Map, receiver: InetAddress?): String? { + val base = baseUrl(receiver) ?: return null + return "$base/s/${encode(target, headers)}/subtitles.vtt" + } + + /** What a receiver needs to be told up front about a stream: its type and HLS segment format. */ + data class StreamProbe(val mimeType: String, val hlsVideoSegmentFormat: String?) + + /** + * Looks at the start of [target] (and, for an HLS master playlist, its first variant) to tell + * HLS from progressive files and TS from fMP4 segments. Blocking; call off the main thread. + */ + fun probe(target: String, headers: Map): StreamProbe { + val path = Uri.parse(target).path.orEmpty().lowercase() + if (path.endsWith(".mpd")) return StreamProbe("application/dash+xml", null) + val playlist = runCatching { readPlaylist(target, headers) }.getOrNull() + ?: return StreamProbe(if (path.endsWith(".m3u8")) HLS_MIME else guessContentType(target).takeUnless { it == "video/mp2t" } ?: "video/mp4", null) + val (text, base) = playlist + val mediaPlaylist = if (text.contains("#EXT-X-STREAM-INF")) { + val variant = text.lineSequence().map { it.trim() } + .dropWhile { !it.startsWith("#EXT-X-STREAM-INF") } + .drop(1) + .firstOrNull { it.isNotEmpty() && !it.startsWith("#") } + variant?.let { resolve(base, it) }?.let { runCatching { readPlaylist(it, headers) }.getOrNull()?.first } + } else { + text + } + val format = when { + mediaPlaylist == null -> null + mediaPlaylist.contains("#EXT-X-MAP") -> HLS_FMP4 + else -> HLS_TS + } + return StreamProbe(HLS_MIME, format) + } + + /** The playlist text and its address after redirects, or null when [target] isn't a playlist. */ + private fun readPlaylist(target: String, headers: Map): Pair? { + val source = dataSourceFactory.createDataSource() + try { + source.open(DataSpec.Builder().setUri(Uri.parse(target)).setHttpRequestHeaders(headers).build()) + val head = readUpTo(source, SNIFF_BYTES) + if (!head.isPlaylist()) return null + val body = head + readRest(source, MAX_PLAYLIST_BYTES) + return body.toString(Charsets.UTF_8) to (source.uri?.toString() ?: target) + } finally { + runCatching { source.close() } + } + } + + private fun baseUrl(receiver: InetAddress?): String? { + val port = start() ?: return null + val host = lanAddressFor(receiver) ?: return null + return "http://$host:$port/$token" + } + + private fun bind(port: Int): ServerSocket = ServerSocket().apply { + reuseAddress = true + bind(InetSocketAddress(port)) + } + + private fun acceptLoop(socket: ServerSocket) { + while (!socket.isClosed) { + val client = try { + socket.accept() + } catch (_: SocketException) { + break + } catch (error: IOException) { + Log.w(TAG, "Accept failed", error) + continue + } + executor.execute { handle(client) } + } + } + + // region HTTP + + private fun handle(socket: Socket) { + socket.use { + runCatching { + socket.soTimeout = 30_000 + val input = BufferedInputStream(socket.getInputStream()) + val output = socket.getOutputStream().buffered(64 * 1024) + val requestLine = readLine(input) ?: return + val requestHeaders = mutableMapOf() + while (true) { + val line = readLine(input) ?: break + if (line.isEmpty()) break + val colon = line.indexOf(':') + if (colon > 0) requestHeaders[line.substring(0, colon).trim().lowercase()] = line.substring(colon + 1).trim() + } + val parts = requestLine.split(' ') + if (parts.size < 2) return + val method = parts[0].uppercase() + if (method == "OPTIONS") { + writeHead(output, 204, "No Content", emptyMap(), 0L) + output.flush() + return + } + if (method != "GET" && method != "HEAD") { + writeError(output, 405, "Method Not Allowed") + return + } + val segments = parts[1].substringBefore('?').split('/').filter { it.isNotEmpty() } + if (segments.size < 3 || segments[0] != token) { + writeError(output, 403, "Forbidden") + return + } + val decoded = decode(segments[2]) ?: run { + writeError(output, 400, "Bad Request") + return + } + val host = requestHeaders["host"] ?: return + when (segments[1]) { + "m" -> serveMedia(decoded.first, decoded.second, requestHeaders["range"], method == "HEAD", host, output) + "s" -> serveSubtitle(decoded.first, decoded.second, method == "HEAD", output) + else -> writeError(output, 404, "Not Found") + } + output.flush() + }.onFailure { error -> + // Receivers drop connections all the time (seeks, quality switches); that is not news. + if (error !is SocketException) Log.w(TAG, "Request failed: ${error.message}") + } + } + } + + private fun serveMedia( + target: String, + headers: Map, + rangeHeader: String?, + headOnly: Boolean, + host: String, + output: OutputStream + ) { + val range = parseRange(rangeHeader) + val start = range?.first ?: 0L + val length = range?.second?.let { end -> end - start + 1 } ?: C.LENGTH_UNSET.toLong() + val source = dataSourceFactory.createDataSource() + try { + val spec = DataSpec.Builder() + .setUri(Uri.parse(target)) + .setHttpRequestHeaders(headers) + .setPosition(start) + .setLength(length) + .build() + val opened = try { + source.open(spec) + } catch (error: HttpDataSource.InvalidResponseCodeException) { + writeError(output, error.responseCode, error.responseMessage ?: "Upstream error") + return + } catch (_: FileNotFoundException) { + writeError(output, 404, "Not Found") + return + } catch (error: IOException) { + Log.w(TAG, "Upstream failed for ${Uri.parse(target).host}: ${error.message}") + writeError(output, 502, "Bad Gateway") + return + } + + val upstreamHeaders = source.responseHeaders + val head = if (start == 0L) readUpTo(source, SNIFF_BYTES) else ByteArray(0) + if (head.isPlaylist()) { + val body = head + readRest(source, MAX_PLAYLIST_BYTES) + val base = source.uri?.toString() ?: target + val rewritten = rewritePlaylist(body.toString(Charsets.UTF_8), base, headers, host) + .toByteArray(Charsets.UTF_8) + writeHead( + output, 200, "OK", + mapOf("Content-Type" to "application/vnd.apple.mpegurl", "Cache-Control" to "no-cache"), + rewritten.size.toLong() + ) + if (!headOnly) output.write(rewritten) + return + } + + val contentType = upstreamHeaders.first("Content-Type") + ?.takeUnless { it.startsWith("image/", ignoreCase = true) || it.startsWith("text/plain", ignoreCase = true) } + ?: guessContentType(target) + val responseHeaders = mutableMapOf("Content-Type" to contentType, "Accept-Ranges" to "bytes") + if (range != null) { + val total = totalLength(upstreamHeaders, target, start, opened, range.second) + val end = when { + opened != C.LENGTH_UNSET.toLong() -> start + opened - 1 + range.second != null -> range.second!! + total != null -> total - 1 + else -> null + } + responseHeaders["Content-Range"] = "bytes $start-${end ?: ""}/${total ?: "*"}" + writeHead(output, 206, "Partial Content", responseHeaders, opened.takeIf { it != C.LENGTH_UNSET.toLong() }) + } else { + writeHead(output, 200, "OK", responseHeaders, opened.takeIf { it != C.LENGTH_UNSET.toLong() }) + } + if (headOnly) return + output.write(head) + val buffer = ByteArray(32 * 1024) + while (true) { + val read = source.read(buffer, 0, buffer.size) + if (read == C.RESULT_END_OF_INPUT) break + output.write(buffer, 0, read) + } + } finally { + runCatching { source.close() } + } + } + + private fun serveSubtitle(target: String, headers: Map, headOnly: Boolean, output: OutputStream) { + val body = subtitleCache[target] ?: runCatching { fetchSubtitleAsVtt(target, headers) } + .onFailure { Log.w(TAG, "Subtitle conversion failed: ${it.message}") } + .getOrNull() + ?.also { subtitleCache[target] = it } + if (body == null) { + writeError(output, 502, "Bad Gateway") + return + } + writeHead(output, 200, "OK", mapOf("Content-Type" to "text/vtt; charset=utf-8"), body.size.toLong()) + if (!headOnly) output.write(body) + } + + private fun fetchSubtitleAsVtt(target: String, headers: Map): ByteArray { + val url = java.net.URL(target) + val connection = url.openConnection() as java.net.HttpURLConnection + try { + if (url.host.endsWith("opensubtitles.org")) { + // OpenSubtitles only asks for a User-Agent; the stream's Referer would be wrong there. + connection.setRequestProperty("User-Agent", OpenSubtitlesRepository.USER_AGENT) + } else { + connection.setRequestProperty("User-Agent", BROWSER_USER_AGENT) + headers.forEach { (name, value) -> connection.setRequestProperty(name, value) } + } + connection.connectTimeout = 10_000 + connection.readTimeout = 15_000 + if (connection.responseCode !in 200..299) throw IOException("HTTP ${connection.responseCode}") + val bytes = connection.inputStream.use { it.readBytes() } + val isGzip = bytes.size > 2 && bytes[0] == 0x1f.toByte() && bytes[1] == 0x8b.toByte() + val text = if (isGzip) { + GZIPInputStream(bytes.inputStream()).bufferedReader().use { it.readText() } + } else { + bytes.toString(Charsets.UTF_8) + } + val cues = parseSubtitles(text).filterNot { it.text.isSubtitleAd() } + if (cues.isEmpty()) throw IOException("No cues") + return cues.toWebVtt().toByteArray(Charsets.UTF_8) + } finally { + connection.disconnect() + } + } + + private fun writeHead( + output: OutputStream, + code: Int, + reason: String, + headers: Map, + contentLength: Long? + ) { + val head = buildString { + append("HTTP/1.1 ").append(code).append(' ').append(reason).append("\r\n") + headers.forEach { (name, value) -> append(name).append(": ").append(value).append("\r\n") } + if (contentLength != null) append("Content-Length: ").append(contentLength).append("\r\n") + // Receivers are web pages: they need CORS, and Chrome's private network access checks. + append("Access-Control-Allow-Origin: *\r\n") + append("Access-Control-Allow-Methods: GET, HEAD, OPTIONS\r\n") + append("Access-Control-Allow-Headers: *\r\n") + append("Access-Control-Allow-Private-Network: true\r\n") + append("Access-Control-Expose-Headers: Content-Length, Content-Range, Accept-Ranges\r\n") + append("Connection: close\r\n\r\n") + } + output.write(head.toByteArray(Charsets.ISO_8859_1)) + } + + private fun writeError(output: OutputStream, code: Int, reason: String) { + writeHead(output, code, reason, mapOf("Content-Type" to "text/plain"), 0L) + output.flush() + } + + // endregion + + // region Playlists + + /** + * Points every URI in an HLS playlist (variants, segments, keys, init sections, renditions) + * back at the proxy, resolved against the playlist's own address after redirects. + */ + internal fun rewritePlaylist(text: String, base: String, headers: Map, host: String): String { + val prefix = "http://$host/$token" + fun proxify(reference: String): String { + val resolved = resolve(base, reference) ?: return reference + val scheme = Uri.parse(resolved).scheme?.lowercase() + if (scheme !in PROXIED_SCHEMES) return reference + return "$prefix/m/${encode(resolved, headers)}/${fileName(resolved, "media")}" + } + return text.lineSequence().joinToString("\n") { raw -> + val line = raw.trim() + when { + line.isEmpty() -> raw + line.startsWith("#") -> URI_ATTRIBUTE.replace(raw) { match -> + "URI=\"${proxify(match.groupValues[1])}\"" + } + else -> proxify(line) + } + } + } + + private fun resolve(base: String, reference: String): String? = + base.toHttpUrlOrNull()?.resolve(reference)?.toString() + ?: runCatching { java.net.URI(base).resolve(reference).toString() }.getOrNull() + + private fun ByteArray.isPlaylist(): Boolean { + val text = toString(Charsets.UTF_8).trimStart('', ' ', '\r', '\n', '\t') + return text.startsWith("#EXTM3U") + } + + // endregion + + // region Helpers + + private fun encode(target: String, headers: Map): String { + val payload = buildString { + append(target) + headers.forEach { (name, value) -> append('\n').append(name).append(": ").append(value) } + } + return Base64.encodeToString(payload.toByteArray(Charsets.UTF_8), BASE64_FLAGS) + } + + private fun decode(segment: String): Pair>? = runCatching { + val lines = Base64.decode(segment, BASE64_FLAGS).toString(Charsets.UTF_8).split('\n') + val headers = lines.drop(1).mapNotNull { line -> + val colon = line.indexOf(": ") + if (colon <= 0) null else line.substring(0, colon) to line.substring(colon + 2) + }.toMap() + lines.first() to headers + }.getOrNull()?.takeIf { it.first.isNotBlank() } + + private fun fileName(target: String, fallback: String): String = + Uri.parse(target).lastPathSegment + ?.replace(Regex("[^A-Za-z0-9._-]"), "_") + ?.take(64) + ?.takeIf { it.isNotBlank() } + ?: fallback + + private fun parseRange(header: String?): Pair? { + val match = header?.let { RANGE.matchEntire(it.trim()) } ?: return null + val start = match.groupValues[1].toLongOrNull() ?: return null + val end = match.groupValues[2].toLongOrNull() + return start to end + } + + private fun totalLength( + upstreamHeaders: Map>, + target: String, + start: Long, + opened: Long, + requestedEnd: Long? + ): Long? { + upstreamHeaders.first("Content-Range")?.substringAfterLast('/')?.toLongOrNull()?.let { return it } + ContentMetadata.getContentLength(cache.getContentMetadata(target)).takeIf { it > 0 }?.let { return it } + return if (requestedEnd == null && opened != C.LENGTH_UNSET.toLong()) start + opened else null + } + + private fun Map>.first(name: String): String? = + entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value?.firstOrNull() + + private fun guessContentType(target: String): String { + val path = Uri.parse(target).path.orEmpty().lowercase() + return when { + path.endsWith(".m3u8") -> "application/vnd.apple.mpegurl" + path.endsWith(".mp4") || path.endsWith(".m4s") || path.endsWith(".m4v") -> "video/mp4" + path.endsWith(".m4a") -> "audio/mp4" + path.endsWith(".aac") -> "audio/aac" + path.endsWith(".vtt") -> "text/vtt" + path.endsWith(".mpd") -> "application/dash+xml" + path.endsWith(".webm") -> "video/webm" + path.endsWith(".mkv") -> "video/x-matroska" + path.endsWith(".key") -> "application/octet-stream" + else -> "video/mp2t" + } + } + + private fun readLine(input: InputStream): String? { + val bytes = ByteArrayOutputStream() + while (true) { + val next = input.read() + if (next == -1) return if (bytes.size() == 0) null else bytes.toString(Charsets.ISO_8859_1.name()) + if (next == '\n'.code) break + if (next != '\r'.code) bytes.write(next) + if (bytes.size() > 16 * 1024) throw IOException("Header line too long") + } + return bytes.toString(Charsets.ISO_8859_1.name()) + } + + private fun readUpTo(source: DataSource, limit: Int): ByteArray { + val bytes = ByteArray(limit) + var filled = 0 + while (filled < limit) { + val read = source.read(bytes, filled, limit - filled) + if (read == C.RESULT_END_OF_INPUT) break + filled += read + } + return if (filled == limit) bytes else bytes.copyOf(filled) + } + + private fun readRest(source: DataSource, limit: Int): ByteArray { + val output = ByteArrayOutputStream() + val buffer = ByteArray(16 * 1024) + while (output.size() < limit) { + val read = source.read(buffer, 0, buffer.size) + if (read == C.RESULT_END_OF_INPUT) break + output.write(buffer, 0, read) + } + return output.toByteArray() + } + + // endregion + + companion object { + private const val TAG = "CastProxy" + private const val KEY_TOKEN = "cast_proxy_token" + private const val KEY_PORT = "cast_proxy_port" + private const val DEFAULT_PORT = 8765 + private const val HLS_MIME = "application/x-mpegURL" + private const val HLS_FMP4 = "fmp4" + private const val HLS_TS = "mpeg2_ts" + private const val SNIFF_BYTES = 16 + private const val MAX_PLAYLIST_BYTES = 16 * 1024 * 1024 + private const val BASE64_FLAGS = Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING + private val PROXIED_SCHEMES = setOf("http", "https", "content", "file") + private val URI_ATTRIBUTE = Regex("URI=\"([^\"]+)\"") + private val RANGE = Regex("bytes=(\\d+)-(\\d*)") + + /** + * This phone's IPv4 address on the network the receiver is on: the interface that shares + * the receiver's subnet, else the first private address on Wi-Fi/Ethernet. + */ + fun lanAddressFor(receiver: InetAddress?): String? { + val candidates = runCatching { NetworkInterface.getNetworkInterfaces()?.toList().orEmpty() } + .getOrDefault(emptyList()) + .filter { runCatching { it.isUp && !it.isLoopback }.getOrDefault(false) } + .flatMap { network -> + network.interfaceAddresses + .filter { it.address is Inet4Address } + .map { network.name.orEmpty() to it } + } + val target = receiver as? Inet4Address + if (target != null) { + candidates.firstOrNull { (_, address) -> + sameSubnet(address.address, target, address.networkPrefixLength.toInt()) + }?.let { return it.second.address.hostAddress } + } + return candidates + .filter { (_, address) -> address.address.isSiteLocalAddress } + .sortedBy { (name, _) -> + when { + name.startsWith("wlan") -> 0 + name.startsWith("eth") || name.startsWith("ap") || name.startsWith("swlan") -> 1 + else -> 2 + } + } + .firstOrNull()?.second?.address?.hostAddress + } + + private fun sameSubnet(local: InetAddress, remote: InetAddress, prefixLength: Int): Boolean { + if (prefixLength !in 1..32) return false + val a = local.address + val b = remote.address + if (a.size != 4 || b.size != 4) return false + val mask = if (prefixLength == 32) -1 else ((-1L shl (32 - prefixLength)) and 0xFFFFFFFFL).toInt() + val left = ((a[0].toInt() and 0xFF) shl 24) or ((a[1].toInt() and 0xFF) shl 16) or + ((a[2].toInt() and 0xFF) shl 8) or (a[3].toInt() and 0xFF) + val right = ((b[0].toInt() and 0xFF) shl 24) or ((b[1].toInt() and 0xFF) shl 16) or + ((b[2].toInt() and 0xFF) shl 8) or (b[3].toInt() and 0xFF) + return (left and mask) == (right and mask) + } + } +} diff --git a/app/src/main/java/com/ivor/openstream/data/cast/CastOptionsProvider.kt b/app/src/main/java/com/ivor/openstream/data/cast/CastOptionsProvider.kt new file mode 100644 index 0000000..7de3b52 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/cast/CastOptionsProvider.kt @@ -0,0 +1,35 @@ +package com.ivor.openstream.data.cast + +import android.content.Context +import com.google.android.gms.cast.CastMediaControlIntent +import com.google.android.gms.cast.framework.CastOptions +import com.google.android.gms.cast.framework.OptionsProvider +import com.google.android.gms.cast.framework.SessionProvider +import com.google.android.gms.cast.framework.media.CastMediaOptions +import com.google.android.gms.cast.framework.media.NotificationOptions +import com.ivor.openstream.MainActivity + +/** + * Read by the Cast framework through the manifest (kept in proguard-rules.pro). Uses Google's + * Default Media Receiver, so no receiver app has to be registered or hosted. The framework's media + * notification and lock-screen controls cover playback on the TV; tapping them opens the app. + */ +class CastOptionsProvider : OptionsProvider { + override fun getCastOptions(context: Context): CastOptions { + val notification = NotificationOptions.Builder() + .setTargetActivityClassName(MainActivity::class.java.name) + .build() + val media = CastMediaOptions.Builder() + .setNotificationOptions(notification) + .setMediaSessionEnabled(true) + .build() + return CastOptions.Builder() + .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) + .setCastMediaOptions(media) + .setStopReceiverApplicationWhenEndingSession(true) + .setResumeSavedSession(true) + .build() + } + + override fun getAdditionalSessionProviders(context: Context): List? = null +} diff --git a/app/src/main/java/com/ivor/openstream/data/local/AppDatabase.kt b/app/src/main/java/com/ivor/openstream/data/local/AppDatabase.kt index ee8e8d8..96bdcec 100644 --- a/app/src/main/java/com/ivor/openstream/data/local/AppDatabase.kt +++ b/app/src/main/java/com/ivor/openstream/data/local/AppDatabase.kt @@ -2,11 +2,14 @@ package com.ivor.openstream.data.local import androidx.room.Database import androidx.room.RoomDatabase +import com.ivor.openstream.data.local.dao.CustomListDao import com.ivor.openstream.data.local.dao.DownloadDao import com.ivor.openstream.data.local.dao.HiddenTitleDao import com.ivor.openstream.data.local.dao.WatchLaterDao import com.ivor.openstream.data.local.dao.IdMappingDao import com.ivor.openstream.data.local.dao.WatchProgressDao +import com.ivor.openstream.data.local.entity.CustomListEntity +import com.ivor.openstream.data.local.entity.CustomListItemEntity import com.ivor.openstream.data.local.entity.DownloadEntity import com.ivor.openstream.data.local.entity.HiddenTitleEntity import com.ivor.openstream.data.local.entity.IdMappingEntity @@ -19,9 +22,11 @@ import com.ivor.openstream.data.local.entity.WatchProgressEntity DownloadEntity::class, IdMappingEntity::class, WatchProgressEntity::class, - HiddenTitleEntity::class + HiddenTitleEntity::class, + CustomListEntity::class, + CustomListItemEntity::class ], - version = 6, + version = 7, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { @@ -30,4 +35,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun idMappingDao(): IdMappingDao abstract fun watchProgressDao(): WatchProgressDao abstract fun hiddenTitleDao(): HiddenTitleDao + abstract fun customListDao(): CustomListDao } diff --git a/app/src/main/java/com/ivor/openstream/data/local/dao/CustomListDao.kt b/app/src/main/java/com/ivor/openstream/data/local/dao/CustomListDao.kt new file mode 100644 index 0000000..9052993 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/local/dao/CustomListDao.kt @@ -0,0 +1,90 @@ +package com.ivor.openstream.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.ivor.openstream.data.local.entity.CustomListEntity +import com.ivor.openstream.data.local.entity.CustomListItemEntity +import kotlinx.coroutines.flow.Flow + +/** A list with its size and the posters of its newest titles, for list cards. */ +data class CustomListSummary( + val id: Long, + val name: String, + val updatedAt: Long, + val itemCount: Int, + /** Up to four poster paths, newest first, joined with '|'. */ + val posters: String? +) + +@Dao +interface CustomListDao { + @Query( + """ + SELECT l.id, l.name, l.updatedAt, + (SELECT COUNT(*) FROM custom_list_items i WHERE i.listId = l.id) AS itemCount, + (SELECT GROUP_CONCAT(posterPath, '|') FROM ( + SELECT posterPath FROM custom_list_items i2 + WHERE i2.listId = l.id AND i2.posterPath IS NOT NULL + ORDER BY i2.addedAt DESC LIMIT 4 + )) AS posters + FROM custom_lists l + ORDER BY l.updatedAt DESC + """ + ) + fun observeSummaries(): Flow> + + @Query("SELECT * FROM custom_lists WHERE id = :listId") + fun observeList(listId: Long): Flow + + @Query("SELECT * FROM custom_lists ORDER BY updatedAt DESC") + suspend fun allLists(): List + + @Query("SELECT * FROM custom_list_items WHERE listId = :listId ORDER BY addedAt DESC") + fun observeItems(listId: Long): Flow> + + @Query("SELECT * FROM custom_list_items") + suspend fun allItems(): List + + /** Ids of the lists that contain a title. */ + @Query("SELECT listId FROM custom_list_items WHERE mediaType = :mediaType AND tmdbId = :tmdbId") + fun observeListIdsFor(mediaType: String, tmdbId: Int): Flow> + + @Insert + suspend fun insertList(list: CustomListEntity): Long + + @Query("UPDATE custom_lists SET name = :name, updatedAt = :now WHERE id = :listId") + suspend fun rename(listId: Long, name: String, now: Long = System.currentTimeMillis()) + + @Query("UPDATE custom_lists SET updatedAt = :now WHERE id = :listId") + suspend fun touch(listId: Long, now: Long = System.currentTimeMillis()) + + @Query("DELETE FROM custom_lists WHERE id = :listId") + suspend fun deleteListRow(listId: Long) + + @Query("DELETE FROM custom_list_items WHERE listId = :listId") + suspend fun deleteItemsOf(listId: Long) + + @Transaction + suspend fun deleteList(listId: Long) { + deleteItemsOf(listId) + deleteListRow(listId) + } + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertItem(item: CustomListItemEntity) + + @Transaction + suspend fun addItem(item: CustomListItemEntity) { + insertItem(item) + touch(item.listId) + } + + @Query("DELETE FROM custom_list_items WHERE listId = :listId AND mediaType = :mediaType AND tmdbId = :tmdbId") + suspend fun removeItem(listId: Long, mediaType: String, tmdbId: Int) + + @Query("SELECT id FROM custom_lists WHERE name = :name COLLATE NOCASE LIMIT 1") + suspend fun findByName(name: String): Long? +} diff --git a/app/src/main/java/com/ivor/openstream/data/local/entity/CustomListEntity.kt b/app/src/main/java/com/ivor/openstream/data/local/entity/CustomListEntity.kt new file mode 100644 index 0000000..71f6fd1 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/local/entity/CustomListEntity.kt @@ -0,0 +1,30 @@ +package com.ivor.openstream.data.local.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** A list the user made (beyond Watch Later), e.g. "Weekend movies". */ +@Entity(tableName = "custom_lists") +data class CustomListEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = System.currentTimeMillis() +) + +/** A title in a custom list. Deleting a list deletes its items (see CustomListDao.deleteList). */ +@Entity( + tableName = "custom_list_items", + primaryKeys = ["listId", "mediaType", "tmdbId"], + indices = [Index(value = ["mediaType", "tmdbId"])] +) +data class CustomListItemEntity( + val listId: Long, + val tmdbId: Int, + val mediaType: String, + val title: String, + val posterPath: String?, + val voteAverage: Double, + val addedAt: Long = System.currentTimeMillis() +) diff --git a/app/src/main/java/com/ivor/openstream/data/repository/CustomListRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/CustomListRepository.kt new file mode 100644 index 0000000..86970f4 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/repository/CustomListRepository.kt @@ -0,0 +1,39 @@ +package com.ivor.openstream.data.repository + +import com.ivor.openstream.data.local.dao.CustomListDao +import com.ivor.openstream.data.local.dao.CustomListSummary +import com.ivor.openstream.data.local.entity.CustomListEntity +import com.ivor.openstream.data.local.entity.CustomListItemEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** The user's own lists of titles, kept alongside (not instead of) Watch Later. */ +@Singleton +class CustomListRepository @Inject constructor(private val dao: CustomListDao) { + + fun summaries(): Flow> = dao.observeSummaries() + + fun list(listId: Long): Flow = dao.observeList(listId) + + fun items(listId: Long): Flow> = dao.observeItems(listId) + + /** Ids of the lists a title is in. */ + fun listIdsFor(mediaType: String, tmdbId: Int): Flow> = + dao.observeListIdsFor(mediaType, tmdbId).map { it.toSet() } + + /** Creates a list; a name that already exists (ignoring case) returns that list instead. */ + suspend fun create(name: String): Long { + val trimmed = name.trim() + return dao.findByName(trimmed) ?: dao.insertList(CustomListEntity(name = trimmed)) + } + + suspend fun rename(listId: Long, name: String) = dao.rename(listId, name.trim()) + + suspend fun delete(listId: Long) = dao.deleteList(listId) + + suspend fun add(item: CustomListItemEntity) = dao.addItem(item) + + suspend fun remove(listId: Long, mediaType: String, tmdbId: Int) = dao.removeItem(listId, mediaType, tmdbId) +} diff --git a/app/src/main/java/com/ivor/openstream/data/repository/SkipTimesRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/SkipTimesRepository.kt index 5c3b0d6..f241bd8 100644 --- a/app/src/main/java/com/ivor/openstream/data/repository/SkipTimesRepository.kt +++ b/app/src/main/java/com/ivor/openstream/data/repository/SkipTimesRepository.kt @@ -1,6 +1,9 @@ package com.ivor.openstream.data.repository import android.util.Log +import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT +import com.ivor.openstream.data.streaming.anime.AnimeEpisodeMapper +import com.ivor.openstream.domain.model.MediaIdentity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @@ -23,31 +26,67 @@ enum class SkipType(val label: String) { CREDITS("Skip credits") } -/** A stretch of an episode the player offers to jump past. */ -data class SkipSegment(val type: SkipType, val startMs: Long, val endMs: Long) +/** + * A stretch of an episode the player offers to jump past. [episodeLengthMs] is the length of the + * file the submitter timed (0 when unknown); cuts of different lengths have different times. + */ +data class SkipSegment(val type: SkipType, val startMs: Long, val endMs: Long, val episodeLengthMs: Long = 0L) + +/** + * The segments that fit a video [durationMs] long: per type, the submission timed on the file + * closest in length. Before the duration is known the first submission of each type is used. + */ +fun List.forDuration(durationMs: Long): List = + groupBy { it.type }.values.mapNotNull { sameType -> + if (durationMs <= 0L) sameType.firstOrNull() + else sameType.minByOrNull { if (it.episodeLengthMs > 0) kotlin.math.abs(it.episodeLengthMs - durationMs) else Long.MAX_VALUE / 2 } + }.filter { durationMs <= 0L || it.startMs < durationMs }.sortedBy { it.startMs } /** * Intro, recap and credits times for anime from AniSkip, a community database keyed by MyAnimeList - * id. TMDB doesn't carry MAL ids, so the id comes from AniList's public search, matched on title and, - * for later seasons, the year the season started airing. + * id and the episode number within that MAL entry. * - * Both are keyless public APIs whose behaviour isn't formally documented or guaranteed: AniList - * GraphQL (graphql.anilist.co, rate limited to about 90 requests a minute) and AniSkip v2 + * TMDB doesn't carry MAL ids, and TMDB seasons often don't line up with MAL entries (split cours, + * arc-based seasons, absolute numbering). So the lookup goes through [AnimeEpisodeMapper] first, + * the same TMDB -> AniList mapping the anime sources use (ani.zip + AniList), which also gives the + * episode number inside the entry. Only titles it can't map fall back to AniList's title search, + * matched on format and the year the season started airing. + * + * All of these are keyless public APIs whose behaviour isn't formally documented or guaranteed: + * api.ani.zip, AniList GraphQL (graphql.anilist.co, about 90 requests a minute) and AniSkip v2 * (api.aniskip.com). Any failure just means no skip button. */ @Singleton class SkipTimesRepository @Inject constructor( @Named("StreamingClient") private val client: OkHttpClient, - private val json: Json + private val json: Json, + private val episodeMapper: AnimeEpisodeMapper ) { private val malIds = ConcurrentHashMap() - suspend fun segmentsFor(title: String, isMovie: Boolean, seasonYear: Int?, episode: Int): List = + /** + * @param identity the TMDB title, season and episode being played. + * @param seasonYear the year the TMDB season started airing, for the title-search fallback. + */ + suspend fun segmentsFor(identity: MediaIdentity, seasonYear: Int?): List = withContext(Dispatchers.IO) { + val isMovie = identity.tmdbType == "movie" + val mapped = runCatching { episodeMapper.map(identity) } + .onFailure { Log.w(TAG, "Episode mapping failed for ${identity.title}: ${it.message}") } + .getOrNull() + val fromMapping = mapped?.malId?.let { malId -> + runCatching { skipTimes(malId, if (isMovie) 1 else mapped.episode) } + .onFailure { Log.w(TAG, "AniSkip failed for MAL $malId: ${it.message}") } + .getOrNull() + } + if (!fromMapping.isNullOrEmpty()) return@withContext fromMapping + // Mapped to a MAL entry that simply has no times: a title search would only guess worse. + if (mapped?.malId != null && fromMapping != null) return@withContext emptyList() + runCatching { - val malId = malIdFor(title, isMovie, seasonYear) ?: return@runCatching emptyList() - skipTimes(malId, if (isMovie) 1 else episode) - }.onFailure { Log.w(TAG, "No skip times for $title: ${it.message}") } + val malId = malIdFor(identity.title, isMovie, seasonYear) ?: return@runCatching emptyList() + skipTimes(malId, if (isMovie) 1 else identity.episode) + }.onFailure { Log.w(TAG, "No skip times for ${identity.title}: ${it.message}") } .getOrDefault(emptyList()) } @@ -63,13 +102,20 @@ class SkipTimesRepository @Inject constructor( .url("https://graphql.anilist.co") .post(body.toRequestBody("application/json".toMediaType())) .header("Accept", "application/json") + .header("User-Agent", BROWSER_USER_AGENT) .build() val text = client.newCall(request).execute().use { if (it.isSuccessful) it.body?.string() else null } ?: return null val media = json.decodeFromString(AniListResponse.serializer(), text).data?.page?.media.orEmpty() .filter { it.idMal != null } - val formats = if (isMovie) setOf("MOVIE") else setOf("TV", "TV_SHORT", "ONA") - val candidates = media.filter { it.format in formats }.ifEmpty { media } + // Full series first: ONA shorts and specials often rank above the show itself + // ("Sousou no Frieren: ●● no Mahou" comes before "Sousou no Frieren"). + val formatGroups = if (isMovie) listOf(setOf("MOVIE")) else listOf(setOf("TV"), setOf("TV_SHORT", "ONA")) + val candidates = formatGroups.asSequence() + .map { formats -> media.filter { it.format in formats } } + .firstOrNull { group -> group.isNotEmpty() && (seasonYear == null || group.any { it.startDate?.year == seasonYear }) } + ?: formatGroups.asSequence().map { formats -> media.filter { it.format in formats } }.firstOrNull { it.isNotEmpty() } + ?: return null // Later seasons are separate AniList entries; the one that started the year the TMDB season did wins. val chosen = seasonYear?.let { year -> candidates.firstOrNull { it.startDate?.year == year } } ?: candidates.firstOrNull() @@ -79,9 +125,16 @@ class SkipTimesRepository @Inject constructor( private fun skipTimes(malId: Int, episode: Int): List { val url = "https://api.aniskip.com/v2/skip-times/$malId/$episode" + - "?types[]=op&types[]=ed&types[]=recap&types[]=mixed-op&types[]=mixed-ed&episodeLength=0" - val text = client.newCall(Request.Builder().url(url).build()).execute() - .use { if (it.isSuccessful) it.body?.string() else null } ?: return emptyList() + "?types=op&types=ed&types=recap&types=mixed-op&types=mixed-ed&episodeLength=0" + val request = Request.Builder().url(url).header("User-Agent", BROWSER_USER_AGENT).build() + val text = client.newCall(request).execute().use { response -> + when { + response.isSuccessful -> response.body?.string() + // AniSkip answers 404 when an episode has no submissions. + response.code == 404 -> return emptyList() + else -> throw java.io.IOException("AniSkip returned HTTP ${response.code}") + } + } ?: return emptyList() val response = json.decodeFromString(AniSkipResponse.serializer(), text) if (!response.found) return emptyList() return response.results.mapNotNull { result -> @@ -92,8 +145,12 @@ class SkipTimesRepository @Inject constructor( else -> return@mapNotNull null } val interval = result.interval ?: return@mapNotNull null - SkipSegment(type, (interval.startTime * 1000).toLong(), (interval.endTime * 1000).toLong()) - .takeIf { it.endMs > it.startMs } + SkipSegment( + type = type, + startMs = (interval.startTime * 1000).toLong(), + endMs = (interval.endTime * 1000).toLong(), + episodeLengthMs = (result.episodeLength * 1000).toLong() + ).takeIf { it.endMs > it.startMs } }.sortedBy { it.startMs } } @@ -124,7 +181,11 @@ private data class AniListDate(val year: Int? = null) private data class AniSkipResponse(val found: Boolean = false, val results: List = emptyList()) @Serializable -private data class AniSkipResult(val skipType: String? = null, val interval: AniSkipInterval? = null) +private data class AniSkipResult( + val skipType: String? = null, + val interval: AniSkipInterval? = null, + val episodeLength: Double = 0.0 +) @Serializable private data class AniSkipInterval(val startTime: Double = 0.0, val endTime: Double = 0.0) diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/session/ImagePrefixStrippingDataSource.kt b/app/src/main/java/com/ivor/openstream/data/streaming/ImagePrefixStrippingDataSource.kt similarity index 98% rename from app/src/main/java/com/ivor/openstream/presentation/player/session/ImagePrefixStrippingDataSource.kt rename to app/src/main/java/com/ivor/openstream/data/streaming/ImagePrefixStrippingDataSource.kt index cb35508..3aad8cf 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/session/ImagePrefixStrippingDataSource.kt +++ b/app/src/main/java/com/ivor/openstream/data/streaming/ImagePrefixStrippingDataSource.kt @@ -1,4 +1,4 @@ -package com.ivor.openstream.presentation.player.session +package com.ivor.openstream.data.streaming import android.net.Uri import androidx.media3.common.C diff --git a/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleParser.kt b/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleParser.kt new file mode 100644 index 0000000..728ea61 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleParser.kt @@ -0,0 +1,134 @@ +package com.ivor.openstream.data.subtitles + +import android.util.Log + +/** Parses SRT, WebVTT or ASS/SSA text into cues. */ +fun parseSubtitles(content: String): List { + val cues = mutableListOf() + val cleanContent = content.replace("\ufeff", "").replace("\r\n", "\n").replace("\r", "\n") + + if (cleanContent.contains("[Events]")) { + Log.i("PlayerSubtitles", "Detected ASS/SSA format") + val lines = cleanContent.lines() + val eventsIndex = lines.indexOfFirst { it.trim().contains("[Events]", ignoreCase = true) } + if (eventsIndex != -1) { + val dialogueLines = lines.drop(eventsIndex + 1).filter { it.trim().startsWith("Dialogue:", ignoreCase = true) } + Log.d("PlayerSubtitles", "Found ${dialogueLines.size} Dialogue lines") + for (line in dialogueLines) { + try { + // Dialogue: 0,0:00:28.57,0:00:30.40,Default,,0,0,0,,Text + // Limit is 10 because the text part can contain commas + val parts = line.split(",", limit = 10) + if (parts.size >= 10) { + val start = parseAssTimestamp(parts[1]) + val end = parseAssTimestamp(parts[2]) + // Strip ASS override tags like {\fn...} and handle \N (newline) + val text = parts[9].replace(Regex("\\{[^}]*\\}"), "") + .replace("\\N", "\n") + .replace("\\n", "\n") + .replace("\\h", " ") + .trim() + if (text.isNotEmpty()) { + cues.add(SubtitleCue(start, end, text)) + } + } + } catch (e: Exception) { + // Skip malformed lines + } + } + } + } else { + Log.i("PlayerSubtitles", "Detected SRT/VTT format") + val timestampRegex = Regex("(\\d{2}:\\d{2}:\\d{2}[,.]\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2}[,.]\\d{3})") + val blocks = cleanContent.split(Regex("\\n\\s*\\n")).filter { it.isNotBlank() } + + for (block in blocks) { + val lines = block.lines().filter { it.isNotBlank() } + val match = timestampRegex.find(block) + + if (match != null) { + val start = parseTimestamp(match.groupValues[1]) + val end = parseTimestamp(match.groupValues[2]) + + val textLines = lines.dropWhile { !it.contains("-->") }.drop(1) + val textRaw = textLines.joinToString("\n").trim() + + if (textRaw.isNotEmpty()) { + val cleanedText = textRaw.replace(Regex("<[^>]*>"), "").trim() + if (cleanedText.isNotEmpty()) { + cues.add(SubtitleCue(start, end, cleanedText)) + } + } + } + } + } + + if (cues.isNotEmpty()) { + Log.i("PlayerSubtitles", "Successfully parsed ${cues.size} cues. First: ${cues.first().text}") + } else { + Log.w("PlayerSubtitles", "Parsed 0 cues from content length: ${cleanContent.length}") + } + return cues +} + +data class SubtitleCue(val startMs: Long, val endMs: Long, val text: String) + +private fun parseAssTimestamp(ts: String): Long { + try { + val parts = ts.trim().split(':') + if (parts.size < 3) return 0L + val h = parts[0].toLongOrNull() ?: 0L + val m = parts[1].toLongOrNull() ?: 0L + val sParts = parts[2].split('.') + val s = sParts[0].toLongOrNull() ?: 0L + val ms = if (sParts.size > 1) { + // ASS usually has 2 decimals, e.g. .57 -> 570ms + val msStr = sParts[1].padEnd(3, '0').take(3) + msStr.toLongOrNull() ?: 0L + } else 0L + return (h * 3600 + m * 60 + s) * 1000 + ms + } catch (e: Exception) { + return 0L + } +} + +private fun parseTimestamp(ts: String): Long { + val clean = ts.replace(',', '.') + val parts = clean.split(':') + if (parts.size < 3) return 0L + + val secondsParts = parts[2].split('.') + + val h = parts[0].toLongOrNull() ?: 0L + val m = parts[1].toLongOrNull() ?: 0L + val s = secondsParts[0].toLongOrNull() ?: 0L + val ms = if (secondsParts.size > 1) { + secondsParts[1].padEnd(3, '0').take(3).toLongOrNull() ?: 0L + } else 0L + + return (h * 3600 + m * 60 + s) * 1000 + ms +} + +private val SUBTITLE_AD = Regex("""(?i)(www\.|https?://|\.(link|lt|com|net|org)\b|opensubtitles|osdb|subtitletools)""") + +/** Community subtitle files often open with a promo line (a site address); true for such a cue. */ +fun String.isSubtitleAd(): Boolean = SUBTITLE_AD.containsMatchIn(this) + +/** Cues as a WebVTT file, the caption format Cast receivers read. */ +fun List.toWebVtt(): String = buildString { + append("WEBVTT\n\n") + this@toWebVtt.forEachIndexed { index, cue -> + append(index + 1).append('\n') + append(vttTime(cue.startMs)).append(" --> ").append(vttTime(cue.endMs)).append('\n') + // A blank line would end the cue early. + append(cue.text.lines().filter { it.isNotBlank() }.joinToString("\n")).append("\n\n") + } +} + +private fun vttTime(ms: Long): String { + val clamped = ms.coerceAtLeast(0L) + val h = clamped / 3_600_000 + val m = clamped / 60_000 % 60 + val s = clamped / 1_000 % 60 + return "%02d:%02d:%02d.%03d".format(java.util.Locale.ROOT, h, m, s, clamped % 1_000) +} diff --git a/app/src/main/java/com/ivor/openstream/di/DatabaseModule.kt b/app/src/main/java/com/ivor/openstream/di/DatabaseModule.kt index 33c905b..ffa42ef 100644 --- a/app/src/main/java/com/ivor/openstream/di/DatabaseModule.kt +++ b/app/src/main/java/com/ivor/openstream/di/DatabaseModule.kt @@ -5,6 +5,7 @@ import androidx.room.Room import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.ivor.openstream.data.local.AppDatabase +import com.ivor.openstream.data.local.dao.CustomListDao import com.ivor.openstream.data.local.dao.DownloadDao import com.ivor.openstream.data.local.dao.HiddenTitleDao import com.ivor.openstream.data.local.dao.IdMappingDao @@ -97,6 +98,39 @@ object DatabaseModule { } } + private val migration6To7 = object : Migration(6, 7) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `custom_lists` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` TEXT NOT NULL, + `createdAt` INTEGER NOT NULL, + `updatedAt` INTEGER NOT NULL + ) + """.trimIndent() + ) + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS `custom_list_items` ( + `listId` INTEGER NOT NULL, + `tmdbId` INTEGER NOT NULL, + `mediaType` TEXT NOT NULL, + `title` TEXT NOT NULL, + `posterPath` TEXT, + `voteAverage` REAL NOT NULL, + `addedAt` INTEGER NOT NULL, + PRIMARY KEY(`listId`, `mediaType`, `tmdbId`) + ) + """.trimIndent() + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_custom_list_items_mediaType_tmdbId` " + + "ON `custom_list_items` (`mediaType`, `tmdbId`)" + ) + } + } + @Provides @Singleton fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { @@ -105,7 +139,7 @@ object DatabaseModule { AppDatabase::class.java, "open_stream_db" ) - .addMigrations(migration2To3, migration3To4, migration4To5, migration5To6) + .addMigrations(migration2To3, migration3To4, migration4To5, migration5To6, migration6To7) .fallbackToDestructiveMigration() .build() } @@ -130,6 +164,11 @@ object DatabaseModule { return database.hiddenTitleDao() } + @Provides + fun provideCustomListDao(database: AppDatabase): CustomListDao { + return database.customListDao() + } + @Provides fun provideWatchProgressDao(database: AppDatabase): WatchProgressDao { return database.watchProgressDao() diff --git a/app/src/main/java/com/ivor/openstream/presentation/details/DetailsScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/details/DetailsScreen.kt index 5b038d3..a1fcf56 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/details/DetailsScreen.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/details/DetailsScreen.kt @@ -63,6 +63,13 @@ import androidx.compose.material.icons.filled.Event import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.foundation.layout.navigationBarsPadding +import com.ivor.openstream.presentation.lists.AddToListSheet import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.RemoveDone import androidx.compose.material.icons.filled.Star @@ -145,6 +152,16 @@ fun DetailsScreen( val episodeDownloads by viewModel.episodeDownloads.collectAsState() val episodeProgress by viewModel.episodeProgress.collectAsState() val resumeTarget by viewModel.resumeTarget.collectAsState() + val titleWatched by viewModel.titleWatched.collectAsState() + val titleWatchedBusy by viewModel.titleWatchedBusy.collectAsState() + val lists by viewModel.lists.collectAsState() + val memberOf by viewModel.memberOf.collectAsState() + var showListSheet by remember { mutableStateOf(false) } + var confirmUnwatched by remember { mutableStateOf(false) } + val snackbar = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + viewModel.messages.collect { snackbar.showSnackbar(it) } + } val listState = rememberLazyListState() val collapsed by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } val pullToClose = rememberPullToClose(onClose = onBackClick) @@ -205,6 +222,13 @@ fun DetailsScreen( } }, onToggleSaved = viewModel::toggleWatchLater, + isWatched = titleWatched, + watchedBusy = titleWatchedBusy, + onToggleWatched = { + if (titleWatched) confirmUnwatched = true else viewModel.setTitleWatched(true) + }, + inListCount = memberOf.size, + onAddToList = { showListSheet = true }, onDownload = { if (activeDownloads > 0) { onOpenDownloads() @@ -371,6 +395,42 @@ fun DetailsScreen( shareUrl = (uiState as? DetailsUiState.Success)?.details?.let { "https://www.themoviedb.org/$mediaType/${it.id}" }, onBackClick = onBackClick ) + + SnackbarHost( + hostState = snackbar, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(16.dp) + ) + } + + val loadedTitle = (uiState as? DetailsUiState.Success)?.details?.name + if (showListSheet && loadedTitle != null) { + AddToListSheet( + titleName = loadedTitle, + lists = lists, + memberOf = memberOf, + isInWatchLater = isSaved, + onToggleWatchLater = viewModel::toggleWatchLater, + onToggleList = viewModel::setInList, + onCreateList = viewModel::createListWithTitle, + onDismiss = { showListSheet = false } + ) + } + if (confirmUnwatched && loadedTitle != null) { + AlertDialog( + onDismissRequest = { confirmUnwatched = false }, + title = { Text("Mark $loadedTitle unwatched?") }, + text = { Text("This clears its watch history and resume points, including Continue Watching.") }, + confirmButton = { + TextButton(onClick = { + confirmUnwatched = false + viewModel.setTitleWatched(false) + }) { Text("Mark unwatched") } + }, + dismissButton = { TextButton(onClick = { confirmUnwatched = false }) { Text("Cancel") } } + ) } } @@ -615,6 +675,11 @@ private fun PrimaryActions( trailer: VideoDto?, onPlay: () -> Unit, onToggleSaved: () -> Unit, + isWatched: Boolean, + watchedBusy: Boolean, + onToggleWatched: () -> Unit, + inListCount: Int, + onAddToList: () -> Unit, onDownload: () -> Unit ) { val uriHandler = LocalUriHandler.current @@ -690,6 +755,33 @@ private fun PrimaryActions( ) } } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + ActionTile( + icon = if (isWatched) Icons.Default.DoneAll else Icons.Default.Done, + label = when { + watchedBusy -> "Marking…" + isWatched -> "Watched" + isMovie -> "Mark watched" + else -> "Mark all watched" + }, + highlighted = isWatched, + busy = watchedBusy, + onClick = onToggleWatched, + modifier = Modifier.weight(1f) + ) + ActionTile( + icon = Icons.AutoMirrored.Filled.PlaylistAdd, + label = if (inListCount > 0) "In $inListCount list${if (inListCount == 1) "" else "s"}" else "Add to list", + highlighted = inListCount > 0, + onClick = onAddToList, + modifier = Modifier.weight(1f) + ) + } } } diff --git a/app/src/main/java/com/ivor/openstream/presentation/details/DetailsViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/details/DetailsViewModel.kt index 478ca66..9655b9c 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/details/DetailsViewModel.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/details/DetailsViewModel.kt @@ -9,6 +9,9 @@ import com.ivor.openstream.data.remote.model.SeasonDetailsDto import com.ivor.openstream.data.remote.model.EpisodeDto import com.ivor.openstream.data.remote.model.toAnimeDto import com.ivor.openstream.data.local.entity.DownloadEntity +import com.ivor.openstream.data.local.dao.CustomListSummary +import com.ivor.openstream.data.local.entity.CustomListItemEntity +import com.ivor.openstream.data.repository.CustomListRepository import com.ivor.openstream.domain.model.DownloadTarget import com.ivor.openstream.domain.model.WatchProgress import com.ivor.openstream.domain.repository.AnimeRepository @@ -24,6 +27,11 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import java.time.LocalDate import javax.inject.Inject @HiltViewModel @@ -32,6 +40,7 @@ class DetailsViewModel @Inject constructor( private val watchLaterRepository: WatchLaterRepository, private val downloadRepository: DownloadRepository, private val watchProgressRepository: WatchProgressRepository, + private val listRepository: CustomListRepository, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -169,6 +178,110 @@ class DetailsViewModel @Inject constructor( episodes.forEach { setWatched(it, watched) } } + /** + * True when the movie, or every aired episode of the show (specials aside), is finished. + * Counted from TMDB's season sizes, so it doesn't need every season's episode list. + */ + val titleWatched: StateFlow = combine(titleProgress, _uiState) { rows, state -> + val details = (state as? DetailsUiState.Success)?.details ?: return@combine false + if (mediaType == "movie") return@combine rows.any { it.completed } + val aired = details.airedEpisodeCount() + aired > 0 && rows.count { it.completed && it.season > 0 } >= aired + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + private val _titleWatchedBusy = MutableStateFlow(false) + /** True while every season is being fetched to mark a show watched. */ + val titleWatchedBusy: StateFlow = _titleWatchedBusy.asStateFlow() + + private val _messages = MutableSharedFlow(extraBufferCapacity = 2) + val messages: SharedFlow = _messages.asSharedFlow() + + /** + * Marks the whole title watched (every aired episode of every season, specials aside) or + * unwatched (forgets all its progress). + */ + fun setTitleWatched(watched: Boolean) { + val details = (_uiState.value as? DetailsUiState.Success)?.details ?: return + if (_titleWatchedBusy.value) return + viewModelScope.launch { + if (!watched) { + watchProgressRepository.clearTitle(mediaType, animeId) + _messages.tryEmit("Marked ${details.name} unwatched") + return@launch + } + if (mediaType == "movie") { + setWatched(details.movieEpisode(), true) + return@launch + } + _titleWatchedBusy.value = true + try { + val done = titleProgress.first().filter { it.completed }.map { it.season to it.episode }.toSet() + var marked = 0 + var failedSeasons = 0 + details.seasons.orEmpty() + .filter { it.seasonNumber > 0 && it.episodeCount > 0 && isReleased(it.airDate) } + .sortedBy { it.seasonNumber } + .forEach { season -> + val episodes = repository.getSeasonDetails(animeId, season.seasonNumber).getOrNull()?.episodes + if (episodes == null) { + failedSeasons++ + return@forEach + } + episodes + .filter { isReleased(it.airDate) && (it.seasonNumber to it.episodeNumber) !in done } + .forEach { episode -> + setWatched(episode, true) + marked++ + } + } + _messages.tryEmit( + when { + failedSeasons > 0 -> "Couldn't load $failedSeasons season(s). Check your connection and try again." + marked == 0 -> "Everything aired is already watched" + else -> "Marked $marked episode${if (marked == 1) "" else "s"} watched" + } + ) + } finally { + _titleWatchedBusy.value = false + } + } + } + + /** The user's lists, for the "Add to list" sheet. */ + val lists: StateFlow> = listRepository.summaries() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** Lists this title is in. */ + val memberOf: StateFlow> = listRepository.listIdsFor(mediaType, animeId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptySet()) + + fun setInList(listId: Long, add: Boolean) { + val details = (_uiState.value as? DetailsUiState.Success)?.details ?: return + viewModelScope.launch { + if (add) listRepository.add(details.asListItem(listId)) else listRepository.remove(listId, mediaType, animeId) + } + } + + /** Makes a list (or finds one with that name) and puts this title in it. */ + fun createListWithTitle(name: String) { + val details = (_uiState.value as? DetailsUiState.Success)?.details ?: return + if (name.isBlank()) return + viewModelScope.launch { + val listId = listRepository.create(name) + listRepository.add(details.asListItem(listId)) + _messages.tryEmit("Added to ${name.trim()}") + } + } + + private fun AnimeDetailsDto.asListItem(listId: Long) = CustomListItemEntity( + listId = listId, + tmdbId = animeId, + mediaType = mediaType, + title = name, + posterPath = posterPath, + voteAverage = voteAverage + ) + /** Download state per episode of this title, keyed by (season, episode). */ val episodeDownloads: StateFlow, DownloadEntity>> = downloadRepository.getDownloadsForTitle(animeId, mediaType) @@ -198,6 +311,41 @@ class DetailsViewModel @Inject constructor( private const val DEFAULT_RUNTIME_MIN = 24 +private fun isReleased(airDate: String?): Boolean { + val date = airDate?.takeIf { it.isNotBlank() } ?: return true + return runCatching { !LocalDate.parse(date.take(10)).isAfter(LocalDate.now()) }.getOrDefault(true) +} + +/** Episodes aired so far across the regular seasons, from season sizes and the next air date. */ +private fun AnimeDetailsDto.airedEpisodeCount(): Int { + val next = nextEpisodeToAir?.takeIf { it.seasonNumber > 0 && !isReleased(it.airDate) } + return seasons.orEmpty() + .filter { it.seasonNumber > 0 && isReleased(it.airDate) } + .sumOf { season -> + when { + next == null || season.seasonNumber < next.seasonNumber -> season.episodeCount + season.seasonNumber == next.seasonNumber -> (next.episodeNumber - 1).coerceIn(0, season.episodeCount) + else -> 0 + } + } +} + +/** The movie as the single "episode" progress is stored under (season 1, episode 1). */ +private fun AnimeDetailsDto.movieEpisode() = EpisodeDto( + id = id, + name = name, + overview = overview, + voteAverage = voteAverage, + voteCount = voteCount ?: 0, + airDate = date, + episodeNumber = 1, + seasonNumber = 1, + stillPath = backdropPath, + productionCode = "", + runtime = runtime, + showId = id +) + sealed interface DetailsUiState { data object Loading : DetailsUiState data class Success( diff --git a/app/src/main/java/com/ivor/openstream/presentation/lists/CustomListScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/lists/CustomListScreen.kt new file mode 100644 index 0000000..9261962 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/lists/CustomListScreen.kt @@ -0,0 +1,226 @@ +package com.ivor.openstream.presentation.lists + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridItemSpanScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import coil3.compose.AsyncImage +import com.ivor.openstream.data.local.entity.CustomListItemEntity +import com.ivor.openstream.presentation.components.LibraryEmptyState +import com.ivor.openstream.presentation.components.LibraryHeader +import com.ivor.openstream.ui.theme.ExpressiveShapes +import kotlinx.coroutines.launch + +/** One custom list: its titles, and renaming or deleting it. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun CustomListScreen( + onBackClick: () -> Unit, + onOpenTitle: (id: Int, mediaType: String) -> Unit, + viewModel: CustomListViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsState() + val snackbar = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + var menuOpen by remember { mutableStateOf(false) } + var renaming by remember { mutableStateOf(false) } + var confirmDelete by remember { mutableStateOf(false) } + val fullWidth: LazyGridItemSpanScope.() -> GridItemSpan = { GridItemSpan(maxLineSpan) } + + // Deleted (here or from a restore elsewhere): nothing left to show. + LaunchedEffect(state.isLoading, state.list) { + if (!state.isLoading && state.list == null) onBackClick() + } + + val remove: (CustomListItemEntity) -> Unit = { item -> + viewModel.remove(item) + scope.launch { + val result = snackbar.showSnackbar("Removed ${item.title}", actionLabel = "Undo") + if (result == SnackbarResult.ActionPerformed) viewModel.restore(item) + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 120.dp), + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 200.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + item(key = "header", span = fullWidth) { + LibraryHeader( + title = state.list?.name.orEmpty(), + subtitle = if (state.isLoading) null else itemCountLabel(state.items.size), + onBackClick = onBackClick, + trailing = { + Box { + IconButton(onClick = { menuOpen = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "List options") + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem(text = { Text("Rename") }, onClick = { menuOpen = false; renaming = true }) + DropdownMenuItem(text = { Text("Delete list") }, onClick = { menuOpen = false; confirmDelete = true }) + } + } + } + ) + } + when { + state.isLoading -> item(key = "loading", span = fullWidth) { + Box(Modifier.fillMaxWidth().padding(48.dp), contentAlignment = Alignment.Center) { LoadingIndicator() } + } + state.items.isEmpty() -> item(key = "empty", span = fullWidth) { + LibraryEmptyState( + icon = Icons.AutoMirrored.Filled.PlaylistAdd, + title = "This list is empty", + body = "Open any movie or show and tap Add to list." + ) + } + } + items(state.items, key = { "${it.mediaType}:${it.tmdbId}" }) { item -> + ListTitleCard( + item = item, + onOpen = { onOpenTitle(item.tmdbId, item.mediaType) }, + onRemove = { remove(item) }, + modifier = Modifier.animateItem() + ) + } + } + + SnackbarHost( + hostState = snackbar, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 112.dp) + ) + } + + if (renaming) { + ListNameDialog( + title = "Rename list", + confirmLabel = "Rename", + initialName = state.list?.name.orEmpty(), + onConfirm = { name -> + renaming = false + viewModel.rename(name) + }, + onDismiss = { renaming = false } + ) + } + if (confirmDelete) { + AlertDialog( + onDismissRequest = { confirmDelete = false }, + title = { Text("Delete “${state.list?.name.orEmpty()}”?") }, + text = { Text("The list goes away. The titles in it stay in the app and in your other lists.") }, + confirmButton = { + TextButton(onClick = { + confirmDelete = false + viewModel.delete() + }) { Text("Delete") } + }, + dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } } + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ListTitleCard( + item: CustomListItemEntity, + onOpen: () -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier +) { + var menuOpen by remember { mutableStateOf(false) } + val haptics = LocalHapticFeedback.current + Box(modifier = modifier) { + Column( + modifier = Modifier.combinedClickable( + onClick = onOpen, + onLongClick = { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + menuOpen = true + }, + onClickLabel = "Open ${item.title}", + onLongClickLabel = "More options" + ) + ) { + AsyncImage( + model = item.posterPath?.let { "https://image.tmdb.org/t/p/w342$it" }, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(0.68f) + .clip(ExpressiveShapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + ) + Text( + text = item.title, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 8.dp) + ) + if (item.mediaType == "movie") { + Text("Movie", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem(text = { Text("Open") }, onClick = { menuOpen = false; onOpen() }) + DropdownMenuItem(text = { Text("Remove from list") }, onClick = { menuOpen = false; onRemove() }) + } + } +} diff --git a/app/src/main/java/com/ivor/openstream/presentation/lists/CustomListViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/lists/CustomListViewModel.kt new file mode 100644 index 0000000..f2961fa --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/lists/CustomListViewModel.kt @@ -0,0 +1,53 @@ +package com.ivor.openstream.presentation.lists + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ivor.openstream.data.local.entity.CustomListEntity +import com.ivor.openstream.data.local.entity.CustomListItemEntity +import com.ivor.openstream.data.repository.CustomListRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class CustomListUiState( + val isLoading: Boolean = true, + /** Null once loaded means the list was deleted. */ + val list: CustomListEntity? = null, + val items: List = emptyList() +) + +@HiltViewModel +class CustomListViewModel @Inject constructor( + private val repository: CustomListRepository, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + private val listId: Long = checkNotNull(savedStateHandle.get("listId")) + + val uiState: StateFlow = combine(repository.list(listId), repository.items(listId)) { list, items -> + CustomListUiState(isLoading = false, list = list, items = items) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), CustomListUiState()) + + fun rename(name: String) { + if (name.isBlank()) return + viewModelScope.launch { repository.rename(listId, name) } + } + + fun delete() { + viewModelScope.launch { repository.delete(listId) } + } + + fun remove(item: CustomListItemEntity) { + viewModelScope.launch { repository.remove(listId, item.mediaType, item.tmdbId) } + } + + /** Undo: puts the title back with its original date so its position doesn't change. */ + fun restore(item: CustomListItemEntity) { + viewModelScope.launch { repository.add(item) } + } +} diff --git a/app/src/main/java/com/ivor/openstream/presentation/lists/ListComponents.kt b/app/src/main/java/com/ivor/openstream/presentation/lists/ListComponents.kt new file mode 100644 index 0000000..c6f3586 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/lists/ListComponents.kt @@ -0,0 +1,296 @@ +package com.ivor.openstream.presentation.lists + +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material.icons.filled.CheckBox +import androidx.compose.material.icons.filled.CheckBoxOutlineBlank +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.selection.toggleable +import coil3.compose.AsyncImage +import com.ivor.openstream.data.local.dao.CustomListSummary +import com.ivor.openstream.ui.theme.ExpressiveShapes + +/** Asks for a list name; used to create and to rename lists. */ +@Composable +fun ListNameDialog( + title: String, + confirmLabel: String, + initialName: String = "", + onConfirm: (String) -> Unit, + onDismiss: () -> Unit +) { + var name by remember { mutableStateOf(initialName) } + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it.take(MAX_NAME_LENGTH) }, + label = { Text("Name") }, + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focus) + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(name.trim()) }, enabled = name.isNotBlank()) { Text(confirmLabel) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} + +/** + * "Add to list" from a title: Watch Later plus every custom list, each a checkbox row, and a way + * to start a new list with the title in it. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddToListSheet( + titleName: String, + lists: List, + memberOf: Set, + isInWatchLater: Boolean, + onToggleWatchLater: () -> Unit, + onToggleList: (listId: Long, add: Boolean) -> Unit, + onCreateList: (name: String) -> Unit, + onDismiss: () -> Unit +) { + var creating by remember { mutableStateOf(false) } + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column(Modifier.navigationBarsPadding().padding(bottom = 16.dp)) { + Text( + text = "Save $titleName to…", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 8.dp) + .semantics { heading() } + ) + LazyColumn { + item(key = "watch-later") { + ListRow( + name = "Watch later", + detail = null, + checked = isInWatchLater, + icon = { Icon(if (isInWatchLater) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, contentDescription = null) }, + onToggle = { onToggleWatchLater() } + ) + } + items(lists, key = { it.id }) { list -> + val checked = list.id in memberOf + ListRow( + name = list.name, + detail = itemCountLabel(list.itemCount), + checked = checked, + icon = { ListCover(list, Modifier.size(40.dp)) }, + onToggle = { onToggleList(list.id, !checked) } + ) + } + item(key = "new") { + ListItem( + headlineContent = { Text("New list", fontWeight = FontWeight.SemiBold) }, + leadingContent = { Icon(Icons.Default.Add, contentDescription = null) }, + colors = ListItemDefaults.colors(containerColor = androidx.compose.ui.graphics.Color.Transparent), + modifier = Modifier.clickable(role = Role.Button) { creating = true } + ) + } + } + } + } + if (creating) { + ListNameDialog( + title = "New list", + confirmLabel = "Create", + onConfirm = { name -> + creating = false + onCreateList(name) + }, + onDismiss = { creating = false } + ) + } +} + +@Composable +private fun ListRow( + name: String, + detail: String?, + checked: Boolean, + icon: @Composable () -> Unit, + onToggle: () -> Unit +) { + ListItem( + headlineContent = { Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + supportingContent = detail?.let { { Text(it) } }, + leadingContent = icon, + trailingContent = { + Icon( + if (checked) Icons.Default.CheckBox else Icons.Default.CheckBoxOutlineBlank, + contentDescription = null, + tint = if (checked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + colors = ListItemDefaults.colors(containerColor = androidx.compose.ui.graphics.Color.Transparent), + modifier = Modifier.toggleable(value = checked, role = Role.Checkbox, onValueChange = { onToggle() }) + ) +} + +/** Up to four posters in a grid; a list icon when the list is empty. */ +@Composable +fun ListCover(list: CustomListSummary, modifier: Modifier = Modifier) { + val posters = list.posters?.split('|')?.filter { it.isNotBlank() }.orEmpty() + Surface( + shape = ExpressiveShapes.medium, + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = modifier.aspectRatio(1f) + ) { + when { + posters.isEmpty() -> Box(contentAlignment = Alignment.Center) { + Icon( + Icons.AutoMirrored.Filled.PlaylistAdd, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + posters.size < 4 -> AsyncImage( + model = "https://image.tmdb.org/t/p/w185${posters.first()}", + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + else -> Column(Modifier.fillMaxSize()) { + posters.chunked(2).forEach { row -> + Row(Modifier.weight(1f)) { + row.forEach { path -> + AsyncImage( + model = "https://image.tmdb.org/t/p/w154$path", + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .weight(1f) + .fillMaxSize() + ) + } + } + } + } + } + } +} + +/** A list tile for the Saved tab's row of lists. */ +@Composable +fun ListCard(list: CustomListSummary, onClick: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .width(128.dp) + .clip(ExpressiveShapes.medium) + .clickable(onClickLabel = "Open ${list.name}", onClick = onClick) + ) { + ListCover(list, Modifier.fillMaxWidth()) + Text( + text = list.name, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = itemCountLabel(list.itemCount), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** The "New list" tile at the end of the Saved tab's row of lists. */ +@Composable +fun NewListCard(onClick: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .width(128.dp) + .clip(ExpressiveShapes.medium) + .clickable(onClickLabel = "Create a list", onClick = onClick) + ) { + Surface( + shape = ExpressiveShapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + ) { + Box(contentAlignment = Alignment.Center) { + Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(32.dp)) + } + } + Text( + text = "New list", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 8.dp) + ) + } +} + +fun itemCountLabel(count: Int): String = if (count == 1) "1 title" else "$count titles" + +private const val MAX_NAME_LENGTH = 60 diff --git a/app/src/main/java/com/ivor/openstream/presentation/navigation/AppNavigation.kt b/app/src/main/java/com/ivor/openstream/presentation/navigation/AppNavigation.kt index e7732a8..67eed3b 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/navigation/AppNavigation.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/navigation/AppNavigation.kt @@ -36,6 +36,7 @@ import com.ivor.openstream.presentation.update.UpdateScreen import com.ivor.openstream.presentation.settings.SettingsScreen import com.ivor.openstream.presentation.marketplace.MarketplaceScreen import com.ivor.openstream.presentation.player.session.MiniPlayer +import com.ivor.openstream.presentation.lists.CustomListScreen import com.ivor.openstream.presentation.welcome.WelcomeSheet import com.ivor.openstream.presentation.shortcuts.AppShortcut import com.ivor.openstream.presentation.shortcuts.ShortcutRequest @@ -106,6 +107,10 @@ sealed class Screen( fun createRoute(personId: Int) = "person/$personId" } + data object CustomList : Screen("list/{listId}") { + fun createRoute(listId: Long) = "list/$listId" + } + data object Details : Screen("details/{mediaType}/{animeId}") { fun createRoute(mediaType: String, animeId: Int) = "details/$mediaType/$animeId" } @@ -272,6 +277,21 @@ fun AppNavigation( onBackClick = { navController.popBackStack() }, onAnimeClick = { animeId, mediaType -> navController.navigate(Screen.Details.createRoute(mediaType, animeId)) + }, + onOpenList = { listId -> + navController.navigate(Screen.CustomList.createRoute(listId)) + } + ) + } + + composable( + route = Screen.CustomList.route, + arguments = listOf(navArgument("listId") { type = NavType.LongType }) + ) { + CustomListScreen( + onBackClick = { navController.popBackStack() }, + onOpenTitle = { id, type -> + navController.navigate(Screen.Details.createRoute(type, id)) } ) } diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/PictureInPicture.kt b/app/src/main/java/com/ivor/openstream/presentation/player/PictureInPicture.kt index ec5d736..4f4cd05 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/PictureInPicture.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/PictureInPicture.kt @@ -51,6 +51,29 @@ fun rememberIsInPictureInPicture(): Boolean { return isInPip } +/** + * Enters picture-in-picture right away (the player's PiP button), or null when the device has no + * picture-in-picture support. + */ +@Composable +fun rememberEnterPictureInPicture(): (() -> Unit)? { + val context = LocalContext.current + val activity = context.findComponentActivity() ?: return null + val supported = remember(activity) { + activity.packageManager.hasSystemFeature(android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE) + } + if (!supported) return null + return remember(activity) { + { + runCatching { + activity.enterPictureInPictureMode( + PictureInPictureParams.Builder().setAspectRatio(VIDEO_ASPECT_RATIO).build() + ) + } + } + } +} + /** * Lets playback continue in a floating window when the user leaves the app mid-video. * Android 12+ enters automatically; older versions enter on the user-leave hint. diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt index fe716f4..1fc2657 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt @@ -102,6 +102,8 @@ import androidx.media3.common.util.UnstableApi import coil3.compose.AsyncImage import com.ivor.openstream.data.remote.model.SubtitleDto import com.ivor.openstream.presentation.player.components.ExoPlayerView +import com.ivor.openstream.presentation.player.components.CastDeviceSheet +import com.ivor.openstream.presentation.player.components.CastPlaybackView import com.ivor.openstream.presentation.player.components.SourcesPageActions import com.ivor.openstream.presentation.player.components.SourcesPanel import com.ivor.openstream.presentation.player.components.UpNextOverlay @@ -154,6 +156,13 @@ fun PlayerScreen( val appSettings by viewModel.appSettings.collectAsState() val preferredSubtitleLanguage by viewModel.preferredSubtitleLanguage.collectAsState() val skipSegments by viewModel.skipSegments.collectAsState() + val castStatus by viewModel.castStatus.collectAsState() + val castError by viewModel.castError.collectAsState() + val castLoading by viewModel.castLoading.collectAsState() + val castSubtitles by viewModel.castSubtitles.collectAsState() + val isCasting = castStatus.isCasting + var showCastSheet by remember { mutableStateOf(false) } + val enterPictureInPicture = rememberEnterPictureInPicture() var localVideoUrl by rememberSaveable { mutableStateOf(null) } var isResolvingLocalUri by remember { mutableStateOf(downloadId != null) } @@ -180,6 +189,10 @@ fun PlayerScreen( val allSubtitles = remember(remoteSubtitles, providerSubtitles) { (providerSubtitles + remoteSubtitles).distinctBy { it.url } } + // The TV gets the same subtitles, starting with the remembered language. + LaunchedEffect(allSubtitles, preferredSubtitleLanguage) { + viewModel.setCastSubtitleCandidates(allSubtitles, preferredSubtitleLanguage) + } // Hold playback until the saved position is known so a resume never starts from zero. val videoUrl = (localVideoUrl ?: activeServer?.url)?.takeIf { startPositionMs != null } var showUpNext by remember(tmdbId, season, episode) { mutableStateOf(false) } @@ -203,7 +216,7 @@ fun PlayerScreen( var togglePlaybackSignal by remember { mutableIntStateOf(0) } PictureInPictureEffect( - enabled = videoUrl != null, + enabled = videoUrl != null && !isCasting, isPlaying = isVideoPlaying, onTogglePlayback = { togglePlaybackSignal++ } ) @@ -292,6 +305,11 @@ fun PlayerScreen( isFullscreen = false } + // The video is on the TV: the phone shows remote controls inline, not a fullscreen player. + LaunchedEffect(isCasting) { + if (isCasting && isFullscreen) exitFullscreen() + } + // Handle back press in fullscreen -- exit fullscreen instead of navigating back BackHandler(enabled = isFullscreen) { exitFullscreen() @@ -366,7 +384,32 @@ fun PlayerScreen( // targetState still says "has url" but it was just cleared on an // episode switch) can't dereference a null and crash. val currentUrl = videoUrl - if (hasUrl && currentUrl != null) { + if (hasUrl && currentUrl != null && isCasting) { + CastPlaybackView( + castPlayer = viewModel.castPlayer ?: return@AnimatedContent, + deviceName = castStatus.deviceName.orEmpty(), + title = playerTitle, + subtitle = playerSubtitle, + artworkUrl = (currentEpisode?.stillPath ?: mediaDetails?.backdropPath) + ?.let { "https://image.tmdb.org/t/p/w1280$it" }, + isLoading = castLoading, + error = castError, + subtitles = castSubtitles, + seekStepSeconds = appSettings.seekStepSeconds, + onSelectSubtitle = viewModel::selectCastSubtitle, + onRetry = viewModel::retryCast, + onChooseSource = { showServerPicker = true }.takeIf { downloadId == null }, + onNextClick = onNextClick, + onBackClick = onBackClick, + onCastClick = { showCastSheet = true }, + onPlaybackEnded = { + val stoppedBySleepTimer = viewModel.consumeEndedBySleepTimer() + showUpNext = nextEpisode != null && appSettings.autoPlayNext && !stoppedBySleepTimer + }, + onPlaybackReady = viewModel::onPlaybackReady, + modifier = Modifier.fillMaxSize() + ) + } else if (hasUrl && currentUrl != null) { key(activeServer?.id ?: currentUrl) { ExoPlayerView( videoUrl = currentUrl, @@ -433,7 +476,9 @@ fun PlayerScreen( showServerPicker = viewModel.activeServer.value == null } }, - onPlaybackReady = viewModel::onPlaybackReady + onPlaybackReady = viewModel::onPlaybackReady, + onCastClick = { showCastSheet = true }.takeIf { castStatus.supported }, + onPictureInPictureClick = enterPictureInPicture ) } } else { @@ -582,7 +627,8 @@ fun PlayerScreen( // Once a stream plays, Sources is a page inside the player's settings panel. SourcesPanel( - visible = showServerPicker && videoUrl == null && downloadId == null, + // While casting there is no in-player settings panel, so sources open here too. + visible = showServerPicker && (videoUrl == null || isCasting) && downloadId == null, isFullscreen = isFullscreen, state = serversState, actions = sourceActions, @@ -621,6 +667,15 @@ fun PlayerScreen( } } + if (showCastSheet) { + CastDeviceSheet( + selector = viewModel.castRouteSelector, + status = castStatus, + onStopCasting = viewModel::stopCasting, + onDismiss = { showCastSheet = false } + ) + } + SnackbarHost( hostState = snackbarHostState, modifier = Modifier diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt index a46f5f8..4e6d1a3 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt @@ -48,6 +48,13 @@ import com.ivor.openstream.data.repository.SkipSegment import com.ivor.openstream.data.repository.SkipTimesRepository import com.ivor.openstream.data.settings.AppSettingsStore import com.ivor.openstream.presentation.player.session.SleepTimer +import com.ivor.openstream.presentation.player.session.CastError +import com.ivor.openstream.presentation.player.session.CastStatus +import com.ivor.openstream.presentation.player.session.CastSubtitleOption +import com.ivor.openstream.presentation.player.session.CastSubtitles +import com.ivor.openstream.presentation.player.components.SUBTITLES_OFF +import androidx.media3.common.Player +import androidx.mediarouter.media.MediaRouteSelector import javax.inject.Inject private const val KEY_CAPTION_STYLE = "caption_style" @@ -117,6 +124,33 @@ class PlayerViewModel @Inject constructor( fun consumeEndedBySleepTimer(): Boolean = playbackSession.consumeEndedBySleepTimer() + val castStatus: StateFlow = playbackSession.castStatus + val castError: StateFlow = playbackSession.castError + val castLoading: StateFlow = playbackSession.castLoading + val castSubtitles: StateFlow = playbackSession.castSubtitles + + /** Plays on the TV while casting. */ + val castPlayer: Player? get() = playbackSession.castPlayer + + val castRouteSelector: MediaRouteSelector? get() = playbackSession.castRouteSelector + + fun stopCasting() = playbackSession.stopCasting() + + fun retryCast() = playbackSession.retryCast() + + /** Keeps the TV's subtitle list in step with what this screen found. */ + fun setCastSubtitleCandidates(subtitles: List, preferredLanguage: String?) = + playbackSession.setSubtitleCandidates(subtitles, preferredLanguage) + + /** Subtitle picked on the casting screen: shown on the TV and remembered like in the player. */ + fun selectCastSubtitle(option: CastSubtitleOption?) { + playbackSession.selectCastSubtitle(option?.id) + when { + option == null -> setPreferredSubtitleLanguage(SUBTITLES_OFF) + option.language != null -> setPreferredSubtitleLanguage(option.language) + } + } + private val _mediaUri = MutableStateFlow?>(null) /** The screen reports what it handed to the player (stream URL, plus the download id offline). */ @@ -330,7 +364,8 @@ class PlayerViewModel @Inject constructor( backdropPath = details.backdropPath, stillPath = episode?.stillPath, next = next, - server = server + server = server, + startPositionMs = _startPositionMs.value ?: 0L ) }.collect { nowPlaying -> nowPlaying?.let(playbackSession::update) } } @@ -373,7 +408,7 @@ class PlayerViewModel @Inject constructor( loadJob = viewModelScope.launch { launch { _startPositionMs.value = if (continuing != null) { - playbackSession.player.currentPosition + playbackSession.currentPositionMs() } else { watchProgressRepository.get(mediaType, tmdbId, seasonNumber, currentEpisodeNumber) ?.resumePositionMs ?: 0L @@ -449,7 +484,14 @@ class PlayerViewModel @Inject constructor( } else { details.seasons?.firstOrNull { it.seasonNumber == season }?.airDate?.take(4)?.toIntOrNull() } - _skipSegments.value = skipTimesRepository.segmentsFor(details.name, isMovie, seasonYear, episode) + val identity = MediaIdentity( + tmdbId = details.id, + tmdbType = mediaType, + title = details.name, + season = season, + episode = episode + ) + _skipSegments.value = skipTimesRepository.segmentsFor(identity, seasonYear) } private suspend fun findNextEpisode( diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/components/CastComponents.kt b/app/src/main/java/com/ivor/openstream/presentation/player/components/CastComponents.kt new file mode 100644 index 0000000..5babcec --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/player/components/CastComponents.kt @@ -0,0 +1,485 @@ +package com.ivor.openstream.presentation.player.components + +import androidx.compose.animation.AnimatedContent +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cast +import androidx.compose.material.icons.filled.CastConnected +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ClosedCaption +import androidx.compose.material.icons.filled.ClosedCaptionDisabled +import androidx.compose.material.icons.filled.Forward10 +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Replay10 +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.Tv +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +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.draw.blur +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.mediarouter.media.MediaRouteSelector +import androidx.mediarouter.media.MediaRouter +import coil3.compose.AsyncImage +import com.ivor.openstream.presentation.components.ExpressiveBackButton +import com.ivor.openstream.presentation.player.session.CastError +import com.ivor.openstream.presentation.player.session.CastStatus +import com.ivor.openstream.presentation.player.session.CastSubtitleOption +import com.ivor.openstream.presentation.player.session.CastSubtitles +import com.ivor.openstream.ui.theme.ExpressiveShapes +import kotlinx.coroutines.delay + +/** + * Stands in for the video while it plays on a TV: artwork, what's playing where, and remote + * controls for the receiver. Sized like the inline player (16:9), so it fits the same slot. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun CastPlaybackView( + castPlayer: Player, + deviceName: String, + title: String, + subtitle: String, + artworkUrl: String?, + isLoading: Boolean, + error: CastError?, + subtitles: CastSubtitles, + seekStepSeconds: Int, + onSelectSubtitle: (CastSubtitleOption?) -> Unit, + onRetry: () -> Unit, + onChooseSource: (() -> Unit)?, + onNextClick: (() -> Unit)?, + onBackClick: () -> Unit, + onCastClick: () -> Unit, + onPlaybackEnded: () -> Unit, + onPlaybackReady: () -> Unit, + modifier: Modifier = Modifier +) { + var isPlaying by remember { mutableStateOf(castPlayer.isPlaying) } + var playWhenReady by remember { mutableStateOf(castPlayer.playWhenReady) } + var isBuffering by remember { mutableStateOf(castPlayer.playbackState == Player.STATE_BUFFERING) } + var position by remember { mutableLongStateOf(castPlayer.currentPosition) } + var duration by remember { mutableLongStateOf(0L) } + var scrubbing by remember { mutableStateOf(false) } + var scrubValue by remember { mutableFloatStateOf(0f) } + var showSubtitleMenu by remember { mutableStateOf(false) } + + val latestEnded by rememberUpdatedState(onPlaybackEnded) + val latestReady by rememberUpdatedState(onPlaybackReady) + DisposableEffect(castPlayer) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(playing: Boolean) { + isPlaying = playing + } + + override fun onPlayWhenReadyChanged(ready: Boolean, reason: Int) { + playWhenReady = ready + } + + override fun onPlaybackStateChanged(playbackState: Int) { + isBuffering = playbackState == Player.STATE_BUFFERING + when (playbackState) { + Player.STATE_READY -> latestReady() + Player.STATE_ENDED -> latestEnded() + } + } + } + castPlayer.addListener(listener) + onDispose { castPlayer.removeListener(listener) } + } + LaunchedEffect(castPlayer) { + while (true) { + if (!scrubbing) position = castPlayer.currentPosition.coerceAtLeast(0L) + duration = castPlayer.duration.takeIf { it != C.TIME_UNSET && it > 0 } ?: 0L + delay(500) + } + } + + Box(modifier = modifier.background(Color.Black)) { + if (artworkUrl != null) { + AsyncImage( + model = artworkUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + alpha = 0.45f, + modifier = Modifier + .fillMaxSize() + .blur(12.dp) + ) + } + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)) + ) + + Column(Modifier.fillMaxSize()) { + // Top: back, where it's playing, subtitles, cast device. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp) + ) { + ExpressiveBackButton( + onClick = onBackClick, + containerColor = Color.Transparent, + contentColor = Color.White + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 4.dp) + ) { + Text( + text = "Playing on $deviceName", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primaryContainer, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = if (subtitle.isNotBlank()) "$title · $subtitle" else title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.semantics { heading() } + ) + } + if (subtitles.options.isNotEmpty()) { + Box { + IconButton(onClick = { showSubtitleMenu = true }) { + Icon( + if (subtitles.activeId != null) Icons.Default.ClosedCaption else Icons.Default.ClosedCaptionDisabled, + contentDescription = "Subtitles on the TV", + tint = Color.White + ) + } + DropdownMenu(expanded = showSubtitleMenu, onDismissRequest = { showSubtitleMenu = false }) { + SubtitleMenuItem("Off", selected = subtitles.activeId == null) { + showSubtitleMenu = false + onSelectSubtitle(null) + } + subtitles.options.forEach { option -> + SubtitleMenuItem(option.label, selected = option.id == subtitles.activeId) { + showSubtitleMenu = false + onSelectSubtitle(option) + } + } + } + } + } + IconButton(onClick = onCastClick) { + Icon(Icons.Default.CastConnected, contentDescription = "Cast device: $deviceName", tint = Color.White) + } + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + if (error != null && !isLoading) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = error.message, + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + modifier = Modifier.padding(horizontal = 24.dp) + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) { + Button(onClick = onRetry, shape = ExpressiveShapes.medium) { Text("Retry") } + if (onChooseSource != null && error.fromStream) { + OutlinedButton(onClick = onChooseSource, shape = ExpressiveShapes.medium) { + Text("Other source", color = Color.White) + } + } + } + } + } else { + val seekMs = seekStepSeconds * 1_000L + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + FilledTonalIconButton( + onClick = { castPlayer.seekTo((castPlayer.currentPosition - seekMs).coerceAtLeast(0L)) }, + modifier = Modifier.size(48.dp) + ) { + Icon(Icons.Default.Replay10, contentDescription = "Back $seekStepSeconds seconds") + } + Box(contentAlignment = Alignment.Center) { + FilledIconButton( + onClick = { if (playWhenReady) castPlayer.pause() else castPlayer.play() }, + shape = if (isPlaying) ExpressiveShapes.large else ExpressiveShapes.extraLarge, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ), + modifier = Modifier.size(64.dp) + ) { + AnimatedContent(targetState = playWhenReady, label = "castPlayPause") { playing -> + Icon( + if (playing) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (playing) "Pause on TV" else "Play on TV", + modifier = Modifier.size(32.dp) + ) + } + } + if (isLoading || isBuffering) { + LoadingIndicator( + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(84.dp) + ) + } + } + FilledTonalIconButton( + onClick = { + val target = castPlayer.currentPosition + seekMs + castPlayer.seekTo(if (duration > 0) target.coerceAtMost(duration) else target) + }, + modifier = Modifier.size(48.dp) + ) { + Icon(Icons.Default.Forward10, contentDescription = "Forward $seekStepSeconds seconds") + } + if (onNextClick != null) { + FilledTonalIconButton(onClick = onNextClick, modifier = Modifier.size(48.dp)) { + Icon(Icons.Default.SkipNext, contentDescription = "Next episode") + } + } + } + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 4.dp) + ) { + Text( + text = formatTime(if (scrubbing) (scrubValue * duration).toLong() else position), + style = MaterialTheme.typography.labelSmall, + color = Color.White + ) + Slider( + value = if (scrubbing) scrubValue else if (duration > 0) (position.toFloat() / duration).coerceIn(0f, 1f) else 0f, + onValueChange = { + scrubbing = true + scrubValue = it + }, + onValueChangeFinished = { + if (duration > 0) { + val target = (scrubValue * duration).toLong() + castPlayer.seekTo(target) + position = target + } + scrubbing = false + }, + enabled = duration > 0, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = Color.White.copy(alpha = 0.3f) + ), + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) + Text( + text = formatTime(duration), + style = MaterialTheme.typography.labelSmall, + color = Color.White + ) + } + } + } +} + +@Composable +private fun SubtitleMenuItem(label: String, selected: Boolean, onClick: () -> Unit) { + DropdownMenuItem( + text = { Text(label) }, + onClick = onClick, + leadingIcon = { + if (selected) Icon(Icons.Default.Check, contentDescription = "Selected") else Spacer(Modifier.width(24.dp)) + } + ) +} + +/** + * Lists Cast receivers on the network (scanning while open) and, while casting, offers to stop. + * Picking a device starts a session through the Cast framework; [CastStatus] reports the rest. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun CastDeviceSheet( + selector: MediaRouteSelector?, + status: CastStatus, + onStopCasting: () -> Unit, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val router = remember(context) { MediaRouter.getInstance(context.applicationContext) } + var routes by remember { mutableStateOf>(emptyList()) } + + DisposableEffect(selector) { + if (selector == null) return@DisposableEffect onDispose {} + fun refresh() { + routes = router.routes.filter { !it.isDefaultOrBluetooth && it.isEnabled && it.matchesSelector(selector) } + } + val callback = object : MediaRouter.Callback() { + override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) = refresh() + override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) = refresh() + override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) = refresh() + override fun onRouteSelected(router: MediaRouter, route: MediaRouter.RouteInfo) = refresh() + } + router.addCallback(selector, callback, MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN) + refresh() + onDispose { router.removeCallback(callback) } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(bottom = 16.dp) + ) { + Text( + text = if (status.isCasting) "Casting to ${status.deviceName}" else "Cast to a TV", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 8.dp) + .semantics { heading() } + ) + if (status.isCasting) { + Text( + text = "Keep this phone on the same Wi-Fi network: the TV streams through it.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp) + ) + Button( + onClick = { + onStopCasting() + onDismiss() + }, + shape = ExpressiveShapes.medium, + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 16.dp) + .fillMaxWidth() + ) { + Text("Stop casting") + } + return@Column + } + + if (routes.isEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + ) { + LoadingIndicator(modifier = Modifier.size(40.dp)) + Text( + text = if (status.connecting) "Connecting…" else "Looking for Chromecast and Google TV devices on this Wi-Fi…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp) + ) + } + } else { + LazyColumn { + items(routes, key = { it.id }) { route -> + val connecting = route.connectionState == MediaRouter.RouteInfo.CONNECTION_STATE_CONNECTING + ListItem( + headlineContent = { Text(route.name) }, + supportingContent = { + (if (connecting) "Connecting…" else route.description)?.let { Text(it) } + }, + leadingContent = { + Icon( + if (route.deviceType == MediaRouter.RouteInfo.DEVICE_TYPE_TV) Icons.Default.Tv else Icons.Default.Cast, + contentDescription = null + ) + }, + trailingContent = { + if (connecting) LoadingIndicator(modifier = Modifier.size(32.dp)) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable { + router.selectRoute(route) + onDismiss() + } + ) + } + } + } + TextButton(onClick = onDismiss, modifier = Modifier.padding(horizontal = 12.dp)) { + Text("Close") + } + } + } +} diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt b/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt index 5551af9..9b56439 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt @@ -113,6 +113,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.runtime.mutableIntStateOf import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT +import com.ivor.openstream.data.repository.forDuration +import com.ivor.openstream.data.subtitles.SubtitleCue +import com.ivor.openstream.data.subtitles.isSubtitleAd +import com.ivor.openstream.data.subtitles.parseSubtitles @OptIn(UnstableApi::class) @kotlin.OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -170,7 +174,11 @@ fun ExoPlayerView( episodes: List = emptyList(), currentEpisodeNumber: Int = 0, episodeProgress: Map, WatchProgress> = emptyMap(), - onEpisodeSelected: (EpisodeDto) -> Unit = {} + onEpisodeSelected: (EpisodeDto) -> Unit = {}, + /** Opens the Cast device picker; null hides the Cast button. */ + onCastClick: (() -> Unit)? = null, + /** Pops the video out into picture-in-picture; null hides the button. */ + onPictureInPictureClick: (() -> Unit)? = null ) { val context = LocalContext.current val activity = remember(context) { @@ -1202,6 +1210,13 @@ fun ExoPlayerView( changeVideoScale(videoScale.next()) areControlsVisible = true }, + onCastClick = onCastClick, + onPictureInPictureClick = onPictureInPictureClick?.let { enter -> + { + areControlsVisible = false + enter() + } + }, onBackClick = onBackClick ) @@ -1326,7 +1341,8 @@ fun ExoPlayerView( // Skip intro / recap / credits. AniSkip segments show whenever playback is inside one; without // them, a manual jump is offered early in the video while the controls are up. var manualSkipUsed by remember(videoUrl) { mutableStateOf(false) } - val activeSegment = skipSegments.firstOrNull { currentTime >= it.startMs && currentTime < it.endMs - 1_000 } + val fittingSegments = remember(skipSegments, totalTime / 10_000) { skipSegments.forDuration(totalTime) } + val activeSegment = fittingSegments.firstOrNull { currentTime >= it.startMs && currentTime < it.endMs - 1_000 } val offerManualSkip = skipSegments.isEmpty() && !manualSkipUsed && areControlsVisible && totalTime > MANUAL_SKIP_MS * 4 && currentTime in 5_000L..MANUAL_SKIP_WINDOW_MS val skipLabel = activeSegment?.type?.label ?: "Skip ${MANUAL_SKIP_MS / 1_000}s" @@ -1481,74 +1497,6 @@ fun ExoPlayerView( } } -private fun parseSubtitles(content: String): List { - val cues = mutableListOf() - val cleanContent = content.replace("\ufeff", "").replace("\r\n", "\n").replace("\r", "\n") - - if (cleanContent.contains("[Events]")) { - Log.i("PlayerSubtitles", "Detected ASS/SSA format") - val lines = cleanContent.lines() - val eventsIndex = lines.indexOfFirst { it.trim().contains("[Events]", ignoreCase = true) } - if (eventsIndex != -1) { - val dialogueLines = lines.drop(eventsIndex + 1).filter { it.trim().startsWith("Dialogue:", ignoreCase = true) } - Log.d("PlayerSubtitles", "Found ${dialogueLines.size} Dialogue lines") - for (line in dialogueLines) { - try { - // Dialogue: 0,0:00:28.57,0:00:30.40,Default,,0,0,0,,Text - // Limit is 10 because the text part can contain commas - val parts = line.split(",", limit = 10) - if (parts.size >= 10) { - val start = parseAssTimestamp(parts[1]) - val end = parseAssTimestamp(parts[2]) - // Strip ASS override tags like {\fn...} and handle \N (newline) - val text = parts[9].replace(Regex("\\{[^}]*\\}"), "") - .replace("\\N", "\n") - .replace("\\n", "\n") - .replace("\\h", " ") - .trim() - if (text.isNotEmpty()) { - cues.add(SubtitleCue(start, end, text)) - } - } - } catch (e: Exception) { - // Skip malformed lines - } - } - } - } else { - Log.i("PlayerSubtitles", "Detected SRT/VTT format") - val timestampRegex = Regex("(\\d{2}:\\d{2}:\\d{2}[,.]\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2}[,.]\\d{3})") - val blocks = cleanContent.split(Regex("\\n\\s*\\n")).filter { it.isNotBlank() } - - for (block in blocks) { - val lines = block.lines().filter { it.isNotBlank() } - val match = timestampRegex.find(block) - - if (match != null) { - val start = parseTimestamp(match.groupValues[1]) - val end = parseTimestamp(match.groupValues[2]) - - val textLines = lines.dropWhile { !it.contains("-->") }.drop(1) - val textRaw = textLines.joinToString("\n").trim() - - if (textRaw.isNotEmpty()) { - val cleanedText = textRaw.replace(Regex("<[^>]*>"), "").trim() - if (cleanedText.isNotEmpty()) { - cues.add(SubtitleCue(start, end, cleanedText)) - } - } - } - } - } - - if (cues.isNotEmpty()) { - Log.i("PlayerSubtitles", "Successfully parsed ${cues.size} cues. First: ${cues.first().text}") - } else { - Log.w("PlayerSubtitles", "Parsed 0 cues from content length: ${cleanContent.length}") - } - return cues -} - @Composable private fun GestureIndicator( icon: androidx.compose.ui.graphics.vector.ImageVector, @@ -1665,44 +1613,6 @@ private fun currentBrightness(activity: Activity?): Float { return (system / 255f).coerceIn(0f, 1f) } -private data class SubtitleCue(val startMs: Long, val endMs: Long, val text: String) - -private fun parseAssTimestamp(ts: String): Long { - try { - val parts = ts.trim().split(':') - if (parts.size < 3) return 0L - val h = parts[0].toLongOrNull() ?: 0L - val m = parts[1].toLongOrNull() ?: 0L - val sParts = parts[2].split('.') - val s = sParts[0].toLongOrNull() ?: 0L - val ms = if (sParts.size > 1) { - // ASS usually has 2 decimals, e.g. .57 -> 570ms - val msStr = sParts[1].padEnd(3, '0').take(3) - msStr.toLongOrNull() ?: 0L - } else 0L - return (h * 3600 + m * 60 + s) * 1000 + ms - } catch (e: Exception) { - return 0L - } -} - -private fun parseTimestamp(ts: String): Long { - val clean = ts.replace(',', '.') - val parts = clean.split(':') - if (parts.size < 3) return 0L - - val secondsParts = parts[2].split('.') - - val h = parts[0].toLongOrNull() ?: 0L - val m = parts[1].toLongOrNull() ?: 0L - val s = secondsParts[0].toLongOrNull() ?: 0L - val ms = if (secondsParts.size > 1) { - secondsParts[1].padEnd(3, '0').take(3).toLongOrNull() ?: 0L - } else 0L - - return (h * 3600 + m * 60 + s) * 1000 + ms -} - /** English name for a real ISO 639 code (`ja`, `jpn`, `pt-BR`), or null for junk and `und`. */ private fun displayLanguageOrNull(code: String): String? { val normalized = code.trim().replace('_', '-') @@ -1717,8 +1627,3 @@ private fun SubtitleDto.isSideloadOnly(): Boolean = /** Caption size in picture-in-picture relative to the user's chosen size. */ private const val PIP_CAPTION_SCALE = 0.55f - -/** Community subtitle files often open with a promo line (a site address); drop those cues. */ -private val SUBTITLE_AD = Regex("""(?i)(www\.|https?://|\.(link|lt|com|net|org)\b|opensubtitles|osdb|subtitletools)""") - -private fun String.isSubtitleAd(): Boolean = SUBTITLE_AD.containsMatchIn(this) diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/components/PlayerControls.kt b/app/src/main/java/com/ivor/openstream/presentation/player/components/PlayerControls.kt index 04e7de7..184fa9d 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/components/PlayerControls.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/components/PlayerControls.kt @@ -42,6 +42,8 @@ import androidx.compose.material.icons.filled.Replay30 import androidx.compose.material.icons.filled.Replay5 import androidx.compose.material.icons.filled.ScreenLockRotation import androidx.compose.material.icons.filled.ScreenRotation +import androidx.compose.material.icons.filled.Cast +import androidx.compose.material.icons.filled.PictureInPictureAlt import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material3.ExperimentalMaterial3Api @@ -146,6 +148,10 @@ fun PlayerControls( seekStepSeconds: Int = 10, videoScale: VideoScale = VideoScale.FIT, onVideoScaleClick: () -> Unit = {}, + /** Opens the Cast device picker; null hides the button (no Google Play services). */ + onCastClick: (() -> Unit)? = null, + /** Pops the video out into picture-in-picture; null hides the button. */ + onPictureInPictureClick: (() -> Unit)? = null, onBackClick: () -> Unit ) { val metrics = if (isFullscreen) FullscreenMetrics else InlineMetrics @@ -187,6 +193,8 @@ fun PlayerControls( onLockClick = onLockClick, isRotationLocked = isRotationLocked, onRotationLockToggle = onRotationLockToggle, + onCastClick = onCastClick, + onPictureInPictureClick = onPictureInPictureClick, modifier = Modifier.align(Alignment.TopCenter) ) @@ -235,6 +243,8 @@ private fun TopBar( onLockClick: () -> Unit, isRotationLocked: Boolean, onRotationLockToggle: () -> Unit, + onCastClick: (() -> Unit)?, + onPictureInPictureClick: (() -> Unit)?, modifier: Modifier = Modifier ) { Row( @@ -283,7 +293,8 @@ private fun TopBar( HudChip(icon = Icons.Default.Dns, label = label, onClick = onSourcesClick) } HudChip(label = qualityLabel, onClick = onQualityClick) - } else if (sourceLabel != null) { + } else if (sourceLabel != null && (onCastClick == null || onPictureInPictureClick == null)) { + // Inline space is tight: with Cast and PiP showing, sources stay under Settings. HudIconButton(Icons.Default.Dns, "Change source: $sourceLabel", onSourcesClick) } if (hasSubtitles) { @@ -293,6 +304,8 @@ private fun TopBar( onClick = onSubtitlesClick ) } + onPictureInPictureClick?.let { HudIconButton(Icons.Default.PictureInPictureAlt, "Picture in picture", it) } + onCastClick?.let { HudIconButton(Icons.Default.Cast, "Cast to a TV", it) } HudIconButton(Icons.Default.Settings, "Playback settings", onSettingsClick) } } diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/session/MiniPlayer.kt b/app/src/main/java/com/ivor/openstream/presentation/player/session/MiniPlayer.kt index 984c98b..6e9b402 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/session/MiniPlayer.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/session/MiniPlayer.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CastConnected import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow @@ -44,6 +45,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -80,13 +83,15 @@ fun MiniPlayer( val session = viewModel.session val nowPlaying by session.nowPlaying.collectAsState() val isPlaying by session.isPlaying.collectAsState() + val castStatus by session.castStatus.collectAsState() val item = nowPlaying ?: return var progress by remember { mutableFloatStateOf(0f) } LaunchedEffect(item.mediaUri) { while (true) { - val duration = session.player.duration - progress = if (duration > 0) (session.player.currentPosition.toFloat() / duration).coerceIn(0f, 1f) else 0f + val active = session.activePlayer + val duration = active.duration + progress = if (duration > 0) (active.currentPosition.toFloat() / duration).coerceIn(0f, 1f) else 0f delay(500) } } @@ -95,7 +100,8 @@ fun MiniPlayer( val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_STOP) session.player.pause() + // A TV keeps playing when the phone locks or the app goes to the background. + if (event == Lifecycle.Event.ON_STOP && !session.castStatus.value.isCasting) session.player.pause() } lifecycleOwner.lifecycle.addObserver(observer) onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } @@ -156,7 +162,23 @@ fun MiniPlayer( .clip(ExpressiveShapes.medium) .background(Color.Black) ) { - AndroidView( + if (castStatus.isCasting) { + AsyncImage( + model = (item.stillPath ?: item.backdropPath ?: item.posterPath) + ?.let { "https://image.tmdb.org/t/p/w300$it" }, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + Icon( + Icons.Default.CastConnected, + contentDescription = null, + tint = Color.White, + modifier = Modifier + .align(Alignment.Center) + .size(28.dp) + ) + } else AndroidView( factory = { context -> PlayerView(context).apply { useController = false @@ -184,9 +206,10 @@ fun MiniPlayer( maxLines = 1, overflow = TextOverflow.Ellipsis ) - if (item.subtitle.isNotEmpty()) { + val detail = castStatus.deviceName?.let { "Casting to $it" } ?: item.subtitle + if (detail.isNotEmpty()) { Text( - text = item.subtitle, + text = detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/session/PlaybackSession.kt b/app/src/main/java/com/ivor/openstream/presentation/player/session/PlaybackSession.kt index 3152fa2..317e50b 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/session/PlaybackSession.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/session/PlaybackSession.kt @@ -2,8 +2,15 @@ package com.ivor.openstream.presentation.player.session import android.content.Context import android.net.Uri +import android.os.Bundle +import android.util.Log import androidx.annotation.OptIn +import androidx.media3.cast.CastPlayer +import androidx.media3.cast.SessionAvailabilityListener import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DefaultDataSource @@ -15,12 +22,24 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.util.EventLogger +import androidx.mediarouter.media.MediaRouteSelector +import com.google.android.gms.cast.TextTrackStyle +import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.CastSession +import com.google.android.gms.cast.framework.CastState import com.ivor.openstream.BuildConfig +import com.ivor.openstream.data.cast.CastMediaItemConverter +import com.ivor.openstream.data.cast.CastMediaProxy +import com.ivor.openstream.data.remote.model.SubtitleDto +import com.ivor.openstream.data.streaming.DownloadRequestHeaderStore import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT +import com.ivor.openstream.data.streaming.ImagePrefixStrippingDataSource import com.ivor.openstream.domain.model.VideoServer import com.ivor.openstream.domain.model.WatchProgress import com.ivor.openstream.domain.repository.WatchProgressRepository import com.ivor.openstream.presentation.player.NextEpisodeTarget +import com.ivor.openstream.presentation.player.components.SUBTITLES_OFF +import com.ivor.openstream.presentation.player.components.sameLanguage import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -31,6 +50,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.IOException +import java.net.InetAddress import javax.inject.Inject import javax.inject.Singleton import kotlin.math.abs @@ -49,10 +71,15 @@ data class NowPlaying( val backdropPath: String?, val stillPath: String?, val next: NextEpisodeTarget?, - val server: VideoServer? + val server: VideoServer?, + /** Where playback of this item starts when it is (re)loaded on a new player, such as a TV. */ + val startPositionMs: Long = 0L ) { val isMovie: Boolean get() = mediaType == "movie" + /** Identifies the title and episode, whichever stream or player is showing it. */ + val key: String get() = "$mediaType:$tmdbId:$season:$episode" + val subtitle: String get() = if (isMovie) "" else "S$season · E$episode" + (episodeTitle?.let { " · $it" } ?: "") @@ -60,6 +87,27 @@ data class NowPlaying( this.mediaType == mediaType && this.tmdbId == tmdbId && this.season == season && this.episode == episode } +/** What the Cast button and the casting screen show. */ +data class CastStatus( + /** Google Play services Cast works on this device. */ + val supported: Boolean = false, + /** A receiver was seen on the network. */ + val devicesAvailable: Boolean = false, + val connecting: Boolean = false, + /** Name of the receiver while a session is up; null when not casting. */ + val deviceName: String? = null +) { + val isCasting: Boolean get() = deviceName != null +} + +/** Something that went wrong while casting. [fromStream] errors mean the receiver couldn't play it. */ +data class CastError(val message: String, val fromStream: Boolean) + +/** A subtitle the TV can show; [id] matches the [SubtitleDto] it came from. */ +data class CastSubtitleOption(val id: String, val label: String, val language: String?) + +data class CastSubtitles(val options: List = emptyList(), val activeId: String? = null) + /** A pending stop: after a set time, or when the current episode or movie ends. */ sealed interface SleepTimer { data class After(val minutes: Int, val endsAtMs: Long) : SleepTimer @@ -70,13 +118,19 @@ sealed interface SleepTimer { * One player for the whole app. The player screen attaches to it; leaving that screen keeps * playback going in the mini player instead of tearing the stream down. Watch progress is saved * here, so it keeps being recorded whichever surface is showing the video. + * + * Casting: while a Cast session is up, [castPlayer] plays instead of the local [player]. Connecting + * moves the current item to the TV at the phone's position; disconnecting brings it back to the + * phone, paused where the TV was. Media reaches the receiver through [CastMediaProxy]. */ @OptIn(UnstableApi::class) @Singleton class PlaybackSession @Inject constructor( @ApplicationContext private val context: Context, private val cache: Cache, - private val watchProgressRepository: WatchProgressRepository + private val watchProgressRepository: WatchProgressRepository, + private val castProxy: CastMediaProxy, + private val downloadHeaders: DownloadRequestHeaderStore ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) @@ -98,8 +152,82 @@ class PlaybackSession @Inject constructor( private var lastSavedPositionMs = -1L private var completionRecordedFor: String? = null + private var localPlayerCreated = false + + init { + // Saves progress every few seconds, and remembers where the TV is (its state is gone once + // the session ends). + scope.launch { + while (true) { + delay(PROGRESS_TICK_MS) + val remote = castPlayer?.takeIf { castConnected } + if (remote != null) { + if (remote.currentMediaItem?.mediaId == castRequestedKey) { + lastCastPositionMs = remote.currentPosition + remote.duration.takeIf { it != C.TIME_UNSET && it > 0 }?.let { lastCastDurationMs = it } + } + if (remote.isPlaying) recordProgress(force = false) + } else if (localPlayerCreated && player.isPlaying) { + recordProgress(force = false) + } + } + } + } + /** Created on first use, on the main thread, by whichever surface shows video first. */ - val player: ExoPlayer by lazy { buildPlayer() } + val player: ExoPlayer by lazy { buildPlayer().also { localPlayerCreated = true } } + + // region Cast state + + private var castContext: CastContext? = null + private var castInitAttempted = false + + /** Plays on the TV while [CastStatus.isCasting]; null when Cast isn't available. */ + var castPlayer: CastPlayer? = null + private set + + private var castConnected = false + private val _castStatus = MutableStateFlow(CastStatus()) + val castStatus: StateFlow = _castStatus.asStateFlow() + + private val _castError = MutableStateFlow(null) + val castError: StateFlow = _castError.asStateFlow() + + private val _castLoading = MutableStateFlow(false) + val castLoading: StateFlow = _castLoading.asStateFlow() + + private val _castSubtitles = MutableStateFlow(CastSubtitles()) + val castSubtitles: StateFlow = _castSubtitles.asStateFlow() + + /** What was last sent to the receiver (NowPlaying.key and source URL). */ + private var castRequestedKey: String? = null + private var castRequestedUri: String? = null + private var castLoadJob: Job? = null + private var castTextStyleApplied = false + /** Subtitle id -> receiver track id for what the receiver has loaded. */ + private var castLoadedTracks: Map = emptyMap() + private var subtitleCandidates: List = emptyList() + private var preferredSubtitleLanguage: String? = null + /** Last position/duration read from the receiver; its state is gone once the session ends. */ + private var lastCastPositionMs = 0L + private var lastCastDurationMs = 0L + + /** Whichever player is showing the current item: the TV while casting, else the phone. */ + val activePlayer: Player + get() = castPlayer?.takeIf { castConnected } ?: player + + /** The playing position, read from the TV while casting. */ + fun currentPositionMs(): Long = when { + castConnected -> castPlayer?.currentPosition ?: lastCastPositionMs + localPlayerCreated -> player.currentPosition + else -> 0L + } + + /** Route selector for the Cast device picker; null when Cast isn't available. */ + val castRouteSelector: MediaRouteSelector? + get() = runCatching { castContext?.mergedSelector }.getOrNull() + + // endregion fun setRequestHeaders(headers: Map) { requestHeaders = headers @@ -114,10 +242,12 @@ class PlaybackSession @Inject constructor( endedBySleepTimer = false } _nowPlaying.value = nowPlaying + if (castConnected) syncCast() } fun togglePlayback() { - if (player.isPlaying) player.pause() else player.play() + val active = activePlayer + if (active.isPlaying || (castConnected && active.playWhenReady)) active.pause() else active.play() } /** Arms, replaces or (with null) cancels the sleep timer. */ @@ -128,7 +258,7 @@ class PlaybackSession @Inject constructor( if (timer is SleepTimer.After) { sleepJob = scope.launch { delay((timer.endsAtMs - System.currentTimeMillis()).coerceAtLeast(0L)) - player.pause() + activePlayer.pause() _sleepTimer.value = null } } @@ -144,16 +274,387 @@ class PlaybackSession @Inject constructor( return ended } - /** Ends the session: saves where the user stopped and unloads the stream. */ + /** Ends the session: saves where the user stopped and unloads the stream (on the TV too). */ fun stop() { setSleepTimer(null) recordProgress(force = true) - player.stop() - player.clearMediaItems() + if (castConnected) { + castLoadJob?.cancel() + castPlayer?.stop() + castPlayer?.clearMediaItems() + castRequestedKey = null + castRequestedUri = null + castLoadedTracks = emptyMap() + _castSubtitles.value = CastSubtitles() + _castLoading.value = false + _castError.value = null + } + if (localPlayerCreated) { + player.stop() + player.clearMediaItems() + } _nowPlaying.value = null requestHeaders = emptyMap() } + // region Casting + + /** + * Connects to the Cast framework. Call once on the main thread (from the activity); does nothing + * without Google Play services. + */ + fun initCast() { + if (castInitAttempted) return + castInitAttempted = true + val cast = runCatching { CastContext.getSharedInstance(context) } + .onFailure { Log.w(TAG, "Cast unavailable: ${it.message}") } + .getOrNull() ?: return + castContext = cast + val remote = CastPlayer(cast, CastMediaItemConverter()) + remote.addListener(castListener) + remote.setSessionAvailabilityListener(object : SessionAvailabilityListener { + override fun onCastSessionAvailable() = onCastConnected() + override fun onCastSessionUnavailable() = onCastDisconnected() + }) + castPlayer = remote + cast.addCastStateListener { refreshCastStatus() } + refreshCastStatus() + // A session that survived an app restart is already up. + if (remote.isCastSessionAvailable) onCastConnected() + } + + /** Disconnects from the TV; playback carries on (paused) on the phone. */ + fun stopCasting() { + runCatching { castContext?.sessionManager?.endCurrentSession(true) } + } + + /** Hardware volume keys while casting change the TV's volume. Returns true when handled. */ + fun adjustCastVolume(up: Boolean): Boolean { + if (!castConnected) return false + val session = currentCastSession() ?: return false + return runCatching { + session.volume = (session.volume + if (up) VOLUME_STEP else -VOLUME_STEP).coerceIn(0.0, 1.0) + }.isSuccess + } + + /** + * Subtitles the screen found for the current item, and the language the user last chose + * ([SUBTITLES_OFF] for off). The TV gets the preferred language on (re)load. + */ + fun setSubtitleCandidates(candidates: List, preferredLanguage: String?) { + val changed = candidates != subtitleCandidates || preferredLanguage != preferredSubtitleLanguage + subtitleCandidates = candidates + preferredSubtitleLanguage = preferredLanguage + if (!changed) return + updateCastSubtitleOptions() + if (!castConnected || castRequestedKey == null) return + // Subtitles in the user's language showed up after the TV started: reload once to add them. + val wanted = preferredLanguage?.takeUnless { it == SUBTITLES_OFF } ?: return + if (_castSubtitles.value.activeId != null) return + val match = candidates.firstOrNull { sameLanguage(it.language, wanted) } ?: return + if (match.id !in castLoadedTracks) reloadOnCast(forcedSubtitleId = match.id) + } + + /** Shows [id] on the TV (null turns subtitles off). */ + fun selectCastSubtitle(id: String?) { + val client = currentCastSession()?.remoteMediaClient ?: return + if (id == null) { + client.setActiveMediaTracks(LongArray(0)) + _castSubtitles.value = _castSubtitles.value.copy(activeId = null) + return + } + val trackId = castLoadedTracks[id] + if (trackId != null) { + client.setActiveMediaTracks(longArrayOf(trackId)) + _castSubtitles.value = _castSubtitles.value.copy(activeId = id) + } else { + // Receivers can't add tracks to loaded media: reload in place with this one on. + reloadOnCast(forcedSubtitleId = id) + } + } + + /** Tries the current stream on the TV again, from where it was. */ + fun retryCast() = reloadOnCast(forcedSubtitleId = _castSubtitles.value.activeId) + + private fun currentCastSession(): CastSession? = + runCatching { castContext?.sessionManager?.currentCastSession }.getOrNull() + + private fun refreshCastStatus() { + val cast = castContext ?: return + val state = runCatching { cast.castState }.getOrDefault(CastState.NO_DEVICES_AVAILABLE) + _castStatus.value = CastStatus( + supported = true, + devicesAvailable = state != CastState.NO_DEVICES_AVAILABLE, + connecting = state == CastState.CONNECTING, + deviceName = if (castConnected) { + currentCastSession()?.castDevice?.friendlyName ?: "TV" + } else { + null + } + ) + } + + private fun onCastConnected() { + castConnected = true + castTextStyleApplied = false + _castError.value = null + refreshCastStatus() + val item = _nowPlaying.value + if (item == null) { + adoptRemoteItem() + return + } + val position = if (localPlayerCreated) player.currentPosition else item.startPositionMs + if (localPlayerCreated) { + player.pause() + // Keep the item (to resume locally later) but stop buffering it. + player.stop() + } + val remote = castPlayer + if (remote?.currentMediaItem?.mediaId == item.key) { + // Rejoined a session already playing this: keep going. + castRequestedKey = item.key + castRequestedUri = item.mediaUri + return + } + loadOnCast(item, position, forcedSubtitleId = null) + } + + private fun onCastDisconnected() { + recordProgressAt(lastCastPositionMs, lastCastDurationMs) + castConnected = false + castLoadJob?.cancel() + castRequestedKey = null + castRequestedUri = null + castLoadedTracks = emptyMap() + _castSubtitles.value = CastSubtitles(activeId = null) + _castLoading.value = false + _castError.value = null + refreshCastStatus() + _isPlaying.value = false + updateCastSubtitleOptions() + + // Carry on on the phone from where the TV was, paused. + val item = _nowPlaying.value ?: return + val uri = Uri.parse(item.mediaUri) + val local = player + if (local.currentMediaItem?.localConfiguration?.uri != uri) { + setRequestHeaders(headersFor(item)) + local.setMediaItem(MediaItem.fromUri(uri), lastCastPositionMs) + } else { + local.seekTo(lastCastPositionMs) + } + local.playWhenReady = false + local.prepare() + } + + /** The screen changed episode or source while casting: send the new one to the TV. */ + private fun syncCast() { + val item = _nowPlaying.value ?: return + if (item.key == castRequestedKey && item.mediaUri == castRequestedUri) return + val start = if (item.key == castRequestedKey) currentPositionMs() else item.startPositionMs + loadOnCast(item, start, forcedSubtitleId = null) + } + + private fun reloadOnCast(forcedSubtitleId: String?) { + val item = _nowPlaying.value ?: return + if (!castConnected) return + loadOnCast(item, currentPositionMs(), forcedSubtitleId) + } + + private fun loadOnCast(item: NowPlaying, startPositionMs: Long, forcedSubtitleId: String?) { + val remote = castPlayer ?: return + castRequestedKey = item.key + castRequestedUri = item.mediaUri + castTextStyleApplied = false + _castError.value = null + _castLoading.value = true + val headers = headersFor(item) + val subtitles = pickCastSubtitles(forcedSubtitleId) + val receiver = currentCastSession()?.castDevice?.inetAddress + castLoadJob?.cancel() + castLoadJob = scope.launch { + val built = withContext(Dispatchers.IO) { + runCatching { buildCastItem(item, headers, subtitles.first, subtitles.second, receiver) } + } + if (!castConnected || castRequestedKey != item.key) return@launch + built.onSuccess { (mediaItem, tracks) -> + castLoadedTracks = tracks + _castSubtitles.value = _castSubtitles.value.copy(activeId = subtitles.second?.takeIf { it in tracks }) + updateCastSubtitleOptions() + remote.setMediaItem(mediaItem, startPositionMs.coerceAtLeast(0L)) + remote.playWhenReady = true + remote.prepare() + }.onFailure { error -> + Log.w(TAG, "Could not prepare the cast stream", error) + _castLoading.value = false + _castError.value = CastError( + message = error.message ?: "Couldn't send this to the TV.", + fromStream = false + ) + } + } + } + + /** Subtitles to send (the chosen one first) and the id to start with, if any. */ + private fun pickCastSubtitles(forcedId: String?): Pair, String?> { + val preferred = preferredSubtitleLanguage + val activeId = forcedId ?: preferred + ?.takeUnless { it == SUBTITLES_OFF } + ?.let { language -> subtitleCandidates.firstOrNull { sameLanguage(it.language, language) }?.id } + val active = subtitleCandidates.firstOrNull { it.id == activeId } + val ordered = (listOfNotNull(active) + subtitleCandidates.filter { it.id != activeId }) + .distinctBy { it.url } + .take(MAX_CAST_SUBTITLES) + return ordered to active?.id + } + + private fun updateCastSubtitleOptions() { + val options = subtitleCandidates.distinctBy { it.url }.map { subtitle -> + CastSubtitleOption( + id = subtitle.id, + label = subtitle.display ?: subtitle.language?.uppercase() ?: "Subtitles", + language = subtitle.language + ) + } + _castSubtitles.value = _castSubtitles.value.copy(options = options) + } + + /** Blocking: probes the stream and builds proxied URLs. Runs on the IO dispatcher. */ + private fun buildCastItem( + item: NowPlaying, + headers: Map, + subtitles: List, + activeSubtitleId: String?, + receiver: InetAddress? + ): Pair> { + val url = castProxy.mediaUrl(item.mediaUri, headers, receiver) + ?: throw IOException("Connect this phone to the same Wi-Fi network as the TV.") + val probe = castProxy.probe(item.mediaUri, headers) + val tracks = linkedMapOf() + val configurations = subtitles.mapNotNull { subtitle -> + val subtitleUrl = castProxy.subtitleUrl(subtitle.url, headers, receiver) ?: return@mapNotNull null + tracks[subtitle.id] = tracks.size + 1L + MediaItem.SubtitleConfiguration.Builder(Uri.parse(subtitleUrl)) + .setMimeType("text/vtt") + .setId(subtitle.id) + .setLabel(subtitle.display ?: subtitle.language?.uppercase() ?: "Subtitles") + .setLanguage(subtitle.language) + .setSelectionFlags(if (subtitle.id == activeSubtitleId) C.SELECTION_FLAG_DEFAULT else 0) + .build() + } + val extras = Bundle().apply { + putBoolean(CastMediaItemConverter.EXTRA_IS_EPISODE, !item.isMovie) + probe.hlsVideoSegmentFormat?.let { putString(CastMediaItemConverter.EXTRA_HLS_VIDEO_FORMAT, it) } + putString(EXTRA_MEDIA_TYPE, item.mediaType) + putInt(EXTRA_TMDB_ID, item.tmdbId) + putInt(EXTRA_SEASON, item.season) + putInt(EXTRA_EPISODE, item.episode) + putString(EXTRA_SOURCE_URI, item.mediaUri) + item.downloadId?.let { putString(EXTRA_DOWNLOAD_ID, it) } + putString(EXTRA_TITLE, item.title) + item.episodeTitle?.let { putString(EXTRA_EPISODE_TITLE, it) } + item.posterPath?.let { putString(EXTRA_POSTER, it) } + item.backdropPath?.let { putString(EXTRA_BACKDROP, it) } + item.stillPath?.let { putString(EXTRA_STILL, it) } + } + val artwork = (item.backdropPath ?: item.stillPath ?: item.posterPath) + ?.let { Uri.parse("https://image.tmdb.org/t/p/w1280$it") } + val metadata = MediaMetadata.Builder() + .setTitle(item.title) + .setSubtitle(item.subtitle.takeIf { it.isNotEmpty() }) + .setAlbumTitle(item.title.takeUnless { item.isMovie }) + .setArtworkUri(artwork) + .setExtras(extras) + .build() + val mediaItem = MediaItem.Builder() + .setMediaId(item.key) + .setUri(url) + .setMimeType(probe.mimeType) + .setMediaMetadata(metadata) + .setSubtitleConfigurations(configurations) + .build() + return mediaItem to tracks + } + + /** After an app restart the TV may still be playing: rebuild what's playing from its extras. */ + private fun adoptRemoteItem() { + val remote = castPlayer ?: return + val mediaItem = remote.currentMediaItem ?: return + val extras = mediaItem.mediaMetadata.extras ?: return + val source = extras.getString(EXTRA_SOURCE_URI) ?: return + val item = NowPlaying( + mediaType = extras.getString(EXTRA_MEDIA_TYPE) ?: return, + tmdbId = extras.getInt(EXTRA_TMDB_ID), + season = extras.getInt(EXTRA_SEASON, 1), + episode = extras.getInt(EXTRA_EPISODE, 1), + downloadId = extras.getString(EXTRA_DOWNLOAD_ID), + mediaUri = source, + title = extras.getString(EXTRA_TITLE) ?: mediaItem.mediaMetadata.title?.toString().orEmpty(), + episodeTitle = extras.getString(EXTRA_EPISODE_TITLE), + posterPath = extras.getString(EXTRA_POSTER), + backdropPath = extras.getString(EXTRA_BACKDROP), + stillPath = extras.getString(EXTRA_STILL), + next = null, + server = null + ) + castRequestedKey = item.key + castRequestedUri = item.mediaUri + _nowPlaying.value = item + // The receiver keeps fetching through the proxy; bring it back up on its saved port. + scope.launch(Dispatchers.IO) { castProxy.start() } + } + + private fun headersFor(item: NowPlaying): Map = + item.server?.headers?.takeIf { it.isNotEmpty() } + ?: if (item.downloadId != null) downloadHeaders.headersFor(Uri.parse(item.mediaUri)) else emptyMap() + + private val castListener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (!castConnected) return + _isPlaying.value = isPlaying + if (!isPlaying) recordProgress(force = true) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (!castConnected) return + when (playbackState) { + Player.STATE_READY -> { + _castLoading.value = false + _castError.value = null + if (!castTextStyleApplied) { + castTextStyleApplied = true + runCatching { + currentCastSession()?.remoteMediaClient + ?.setTextTrackStyle(TextTrackStyle.fromSystemSettings(context)) + } + } + } + Player.STATE_ENDED -> { + _castLoading.value = false + recordProgress(force = true, ended = true) + if (_sleepTimer.value == SleepTimer.EndOfEpisode) { + endedBySleepTimer = true + setSleepTimer(null) + } + } + else -> Unit + } + } + + override fun onPlayerError(error: PlaybackException) { + if (!castConnected) return + Log.w(TAG, "Receiver error: ${error.errorCodeName} ${error.message}") + _castLoading.value = false + _castError.value = CastError( + message = "The TV couldn't play this source.", + fromStream = true + ) + } + } + + // endregion + private fun buildPlayer(): ExoPlayer { val http = DefaultHttpDataSource.Factory() .setUserAgent(BROWSER_USER_AGENT) @@ -186,11 +687,13 @@ class PlaybackSession @Inject constructor( if (BuildConfig.DEBUG) addAnalyticsListener(EventLogger()) addListener(object : Player.Listener { override fun onIsPlayingChanged(isPlaying: Boolean) { + if (castConnected) return _isPlaying.value = isPlaying if (!isPlaying) recordProgress(force = true) } override fun onPlaybackStateChanged(playbackState: Int) { + if (castConnected) return if (playbackState == Player.STATE_ENDED) { recordProgress(force = true, ended = true) // Registered before the screen's listener, so this is set by the time it asks. @@ -201,26 +704,39 @@ class PlaybackSession @Inject constructor( } } }) - scope.launch { - while (true) { - delay(PROGRESS_TICK_MS) - if (isPlaying) recordProgress(force = false) - } - } } } private fun recordProgress(force: Boolean, ended: Boolean = false) { val item = _nowPlaying.value ?: return - val playingUri = player.currentMediaItem?.localConfiguration?.uri ?: return - // The screen may already describe the next item while the player still plays the last one. - if (playingUri != Uri.parse(item.mediaUri)) return + val active: Player = if (castConnected) { + val remote = castPlayer ?: return + // The screen may already describe the next item while the TV still plays the last one. + if (remote.currentMediaItem?.mediaId != item.key) return + remote + } else { + if (!localPlayerCreated) return + val playingUri = player.currentMediaItem?.localConfiguration?.uri ?: return + // The screen may already describe the next item while the player still plays the last one. + if (playingUri != Uri.parse(item.mediaUri)) return + player + } + val duration = active.duration.takeIf { it != C.TIME_UNSET && it > 0 } ?: return + val position = if (ended) duration else active.currentPosition.coerceAtLeast(0L) + saveProgress(item, position, duration, force) + } - val duration = player.duration.takeIf { it != C.TIME_UNSET && it > 0 } ?: return - val position = if (ended) duration else player.currentPosition.coerceAtLeast(0L) + /** Saves the TV's last known position when the session ends (its player is empty by then). */ + private fun recordProgressAt(position: Long, duration: Long) { + val item = _nowPlaying.value ?: return + if (castRequestedKey != item.key || duration <= 0L) return + saveProgress(item, position, duration, force = true) + } + + private fun saveProgress(item: NowPlaying, position: Long, duration: Long, force: Boolean) { if (position < WatchProgress.MIN_SAVED_POSITION_MS) return - val key = "${item.mediaType}:${item.tmdbId}:${item.season}:${item.episode}" + val key = item.key val completed = position.toFloat() / duration >= WatchProgress.COMPLETION_FRACTION if (completed && completionRecordedFor == key) return if (!force && !completed && abs(position - lastSavedPositionMs) < PROGRESS_SAVE_INTERVAL_MS) return @@ -273,7 +789,23 @@ class PlaybackSession @Inject constructor( } private companion object { + const val TAG = "PlaybackSession" const val PROGRESS_TICK_MS = 1_000L const val PROGRESS_SAVE_INTERVAL_MS = 10_000L + const val VOLUME_STEP = 0.05 + /** Receivers load every track with the media; keep the list short. */ + const val MAX_CAST_SUBTITLES = 12 + + const val EXTRA_MEDIA_TYPE = "openstream.media_type" + const val EXTRA_TMDB_ID = "openstream.tmdb_id" + const val EXTRA_SEASON = "openstream.season" + const val EXTRA_EPISODE = "openstream.episode" + const val EXTRA_SOURCE_URI = "openstream.source_uri" + const val EXTRA_DOWNLOAD_ID = "openstream.download_id" + const val EXTRA_TITLE = "openstream.title" + const val EXTRA_EPISODE_TITLE = "openstream.episode_title" + const val EXTRA_POSTER = "openstream.poster" + const val EXTRA_BACKDROP = "openstream.backdrop" + const val EXTRA_STILL = "openstream.still" } } diff --git a/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsViewModel.kt index 6a921db..6d7168e 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsViewModel.kt @@ -108,6 +108,7 @@ class SettingsViewModel @Inject constructor( if (summary.watchLater > 0) add("${summary.watchLater} saved") if (summary.progress > 0) add("${summary.progress} progress entries") if (summary.hidden > 0) add("${summary.hidden} hidden") + if (summary.lists > 0) add("${summary.lists} lists") }.let { parts -> if (parts.isEmpty()) "Restored settings; your library was already up to date" else "Restored " + parts.joinToString(", ") diff --git a/app/src/main/java/com/ivor/openstream/presentation/watch_later/WatchLaterScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/watch_later/WatchLaterScreen.kt index 0eff41e..7fce70c 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/watch_later/WatchLaterScreen.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/watch_later/WatchLaterScreen.kt @@ -65,6 +65,13 @@ import com.ivor.openstream.presentation.components.ChoiceChips import com.ivor.openstream.presentation.components.LibraryEmptyState import com.ivor.openstream.presentation.components.LibraryHeader import com.ivor.openstream.presentation.components.LocalSearchField +import com.ivor.openstream.presentation.lists.ListCard +import com.ivor.openstream.presentation.lists.ListNameDialog +import com.ivor.openstream.presentation.lists.NewListCard +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items as lazyItems +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import com.ivor.openstream.ui.theme.ExpressiveShapes import kotlinx.coroutines.launch import java.util.Locale @@ -74,9 +81,12 @@ import java.util.Locale fun WatchLaterScreen( onBackClick: () -> Unit, onAnimeClick: (Int, String) -> Unit, + onOpenList: (listId: Long) -> Unit = {}, viewModel: WatchLaterViewModel = hiltViewModel() ) { val state by viewModel.uiState.collectAsState() + val lists by viewModel.lists.collectAsState() + var creatingList by remember { mutableStateOf(false) } val snackbar = remember { SnackbarHostState() } val scope = rememberCoroutineScope() var sortMenuOpen by remember { mutableStateOf(false) } @@ -140,6 +150,38 @@ fun WatchLaterScreen( ) } + // The user's own lists sit above Watch Later; the row ends with "New list". + item(key = "lists", span = fullWidth) { + Column(modifier = Modifier.bleed(16.dp)) { + Text( + text = "Your lists", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(horizontal = 16.dp) + .semantics { heading() } + ) + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(top = 12.dp) + ) { + lazyItems(lists, key = { "list:${it.id}" }) { list -> + ListCard(list = list, onClick = { onOpenList(list.id) }) + } + item(key = "new-list") { NewListCard(onClick = { creatingList = true }) } + } + Text( + text = "Watch later", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, top = 24.dp) + .semantics { heading() } + ) + } + } + if (state.totalCount > 0) { item(key = "search", span = fullWidth) { Column(modifier = Modifier.bleed(16.dp)) { @@ -202,6 +244,18 @@ fun WatchLaterScreen( .padding(bottom = 112.dp) ) } + + if (creatingList) { + ListNameDialog( + title = "New list", + confirmLabel = "Create", + onConfirm = { name -> + creatingList = false + viewModel.createList(name, onOpenList) + }, + onDismiss = { creatingList = false } + ) + } } @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/java/com/ivor/openstream/presentation/watch_later/WatchLaterViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/watch_later/WatchLaterViewModel.kt index 36ee7f7..e0728ea 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/watch_later/WatchLaterViewModel.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/watch_later/WatchLaterViewModel.kt @@ -2,7 +2,9 @@ package com.ivor.openstream.presentation.watch_later import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.ivor.openstream.data.local.dao.CustomListSummary import com.ivor.openstream.data.local.entity.WatchLaterEntity +import com.ivor.openstream.data.repository.CustomListRepository import com.ivor.openstream.domain.model.WatchProgress import com.ivor.openstream.domain.repository.WatchLaterRepository import com.ivor.openstream.domain.repository.WatchProgressRepository @@ -39,9 +41,20 @@ data class SavedUiState( @HiltViewModel class WatchLaterViewModel @Inject constructor( private val repository: WatchLaterRepository, - progressRepository: WatchProgressRepository + progressRepository: WatchProgressRepository, + private val listRepository: CustomListRepository ) : ViewModel() { + /** The user's own lists, most recently changed first. */ + val lists: StateFlow> = listRepository.summaries() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** Creates a list and hands back its id (to open it). */ + fun createList(name: String, onCreated: (Long) -> Unit) { + if (name.isBlank()) return + viewModelScope.launch { onCreated(listRepository.create(name)) } + } + private val query = MutableStateFlow("") private val filter = MutableStateFlow(SavedFilter.ALL) private val sort = MutableStateFlow(SavedSort.RECENTLY_ADDED) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3d593f4..9ffbc77 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -4,7 +4,7 @@ Features OpenStream is missing compared with other streaming apps, grouped by ar ordered roughly by priority. Tick items off as they land; if the code already does something listed here, trust the code and fix this file. -Last reviewed: 2026-09-27. +Last reviewed: 2026-09-28. ## Already in place @@ -20,9 +20,6 @@ source failover, in-app updates with release notes, dynamic color. artwork fail to load without AdGuard. Settings → Network → DNS picks System, AdGuard (default), Cloudflare or Google; every OkHttp client and Coil go through `AppDns`, which falls back to the network's DNS if the resolver is unreachable. -- [ ] **DNS for playback and downloads.** Media3 still uses `DefaultHttpDataSource` (system DNS), and - WebView sources can't take a custom resolver. Switch Media3 to `OkHttpDataSource` if stream - CDNs turn out to be DNS-blocked too. High-risk playback change. ## Player @@ -31,8 +28,9 @@ source failover, in-app updates with release notes, dynamic color. or Android Auto/Wear surface. Highest-impact player gap. - [x] **Pinch to zoom.** Pinch out to fill (crop the black bars), pinch in to fit. - [x] **Resize / aspect button.** Fit, Zoom to fill, Stretch (fullscreen). -- [x] **Skip intro / recap / credits.** AniSkip for anime (MAL id looked up on AniList); a manual - "Skip 85s" early in anything else. +- [x] **Skip intro / recap / credits.** AniSkip for anime; the MAL id and episode come from the same + TMDB -> AniList mapping the anime sources use (title search only as a fallback), and the + submission timed on the closest file length wins. A manual "Skip 85s" early in anything else. - [x] **Subtitle sync offset.** Shift cues earlier or later in the player (sideloaded subtitles only; embedded tracks are rendered by Media3). - [x] **Screen lock.** Ignore touches during playback until unlocked. @@ -52,8 +50,12 @@ source failover, in-app updates with release notes, dynamic color. - [x] **Orientation lock** in fullscreen. - [x] **Remembered audio and subtitle language** applied to every title (original vs. dub, subtitle language), instead of picking per episode. -- [ ] **Chromecast.** Needs `media3-cast`; source URLs with Referer/Origin headers may not play on a - Cast receiver. +- [x] **Chromecast.** Cast button in the player (Default Media Receiver). The phone runs a small LAN + proxy (`CastMediaProxy`) so the TV gets the stream's Referer/Origin headers, CORS, rewritten HLS + playlists, PNG-prefix stripping and WebVTT subtitles; downloads cast from the cache. Playback + moves to the TV at the current position and back to the phone (paused) on disconnect; progress, + mini player, up next, sleep timer, TV subtitles and volume keys work while casting. +- [x] **Picture-in-picture button** in the player's top bar, besides auto-entering on Home. - [ ] **Seekbar thumbnail previews.** Only when the stream ships trick-play images; low priority. Fixed: the brightness gesture now starts from the current brightness, and leaving the player hands @@ -74,8 +76,9 @@ brightness back to the system. - [ ] **Trakt / AniList / MAL sync** for history, progress and ratings (optional sign-in, OAuth; no user-supplied keys). - [x] **Mark watched / unwatched** by season (and per episode from its long-press menu). -- [ ] **Mark a whole title watched / unwatched.** -- [ ] **Custom lists** beyond Watch Later. +- [x] **Mark a whole title watched / unwatched** (every aired episode, specials aside; unwatched asks first). +- [x] **Custom lists** beyond Watch Later: "Add to list" on Details, lists on the Saved tab, rename, + delete, remove with undo; included in backups. - [ ] **New episode notifications** for followed shows (needs WorkManager, not in the app yet). - [ ] **Profiles** with separate history and progress. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f60aa95..6f41d88 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -76,6 +76,7 @@ androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplaye androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" } androidx-media3-exoplayer-dash = { group = "androidx.media3", name = "media3-exoplayer-dash", version.ref = "media3" } androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } +androidx-media3-cast = { group = "androidx.media3", name = "media3-cast", version.ref = "media3" } # Graphics Shapes (Expressive Shapes) androidx-graphics-shapes = { group = "androidx.graphics", name = "graphics-shapes", version.ref = "graphicsShapes" } From a73a2cbedc4c4c9729350421d35e44e9763d8164 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 17:04:01 +0000 Subject: [PATCH 2/6] Profiles: per-person library, "Who's watching?", kids profiles - profiles table (id, name, avatar key, isKids, createdAt), seeded with profile 1 on upgrade and on fresh installs. Active profile id lives in AppSettingsStore as a StateFlow. - Migration(7, 8) (6 -> 7 already added custom lists): watch_later, watch_progress, hidden_titles and custom_lists gain profileId; primary keys include it so two profiles can hold the same title. SQLite can't change primary keys, so those tables are rebuilt and every existing row is backfilled to profile 1. Downloads stay device-wide. - Every DAO query takes a profileId; repositories follow the active id with flatMapLatest and write to it (progress captures the id when recorded, so PlaybackSession and Continue Watching land on the right profile). Switching profile stops playback. - UI: "Who's watching?" (large avatar tiles in Material shapes) on launch with 2+ profiles, avatar button on Home to switch, Settings -> Profiles to add, edit (name, avatar, kids) and delete with confirmation; deleting removes that profile's rows. - Kids profiles: discover requests add certification_country=US and certification.lte (PG / TV-PG); lists TMDB can't filter (trending, airing, search) and shows are checked against the US rating via release_dates / content_ratings, unrated titles hidden. Home hides Settings and updates; leaving takes a 1.5 s hold on the avatar. include_adult=false is now added to every TMDB request. - Backup format v2 stores each profile's library; restoring merges profiles by name, and a v1 backup goes into the active profile. Verified: compileDebugKotlin, unit tests (52 pass), migration SQL matches Room's generated schema, and the 7 -> 8 statements run on a real SQLite database with sample rows. Not run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H8GM7PfnBT2a3jjqSK47hS --- CLAUDE.md | 11 +- .../openstream/data/backup/LibraryBackup.kt | 131 ++++-- .../ivor/openstream/data/local/AppDatabase.kt | 8 +- .../data/local/dao/CustomListDao.kt | 26 +- .../data/local/dao/HiddenTitleDao.kt | 13 +- .../openstream/data/local/dao/ProfileDao.kt | 58 +++ .../data/local/dao/WatchLaterDao.kt | 17 +- .../data/local/dao/WatchProgressDao.kt | 42 +- .../data/local/entity/CustomListEntity.kt | 7 +- .../data/local/entity/HiddenTitleEntity.kt | 5 +- .../data/local/entity/ProfileEntity.kt | 24 + .../data/local/entity/WatchLaterEntity.kt | 8 +- .../data/local/entity/WatchProgressEntity.kt | 8 +- .../ivor/openstream/data/remote/TmdbApi.kt | 10 + .../openstream/data/remote/model/AnimeDto.kt | 3 +- .../data/remote/model/CertificationDto.kt | 25 ++ .../data/repository/AnimeRepositoryImpl.kt | 60 ++- .../data/repository/CustomListRepository.kt | 18 +- .../data/repository/HiddenTitlesRepository.kt | 24 +- .../data/repository/KidsContentFilter.kt | 82 ++++ .../data/repository/ProfileRepository.kt | 86 ++++ .../repository/WatchLaterRepositoryImpl.kt | 26 +- .../repository/WatchProgressRepositoryImpl.kt | 38 +- .../data/settings/AppSettingsStore.kt | 16 + .../com/ivor/openstream/di/DatabaseModule.kt | 100 ++++- .../com/ivor/openstream/di/NetworkModule.kt | 4 + .../ivor/openstream/domain/model/Profile.kt | 11 + .../presentation/home/HomeScreen.kt | 30 +- .../presentation/home/HomeViewModel.kt | 14 +- .../presentation/navigation/AppNavigation.kt | 49 ++- .../presentation/profiles/ProfileAvatar.kt | 82 ++++ .../presentation/profiles/ProfileScreens.kt | 410 ++++++++++++++++++ .../profiles/ProfilesViewModel.kt | 74 ++++ .../presentation/settings/SettingsScreen.kt | 18 +- docs/ROADMAP.md | 3 +- 35 files changed, 1371 insertions(+), 170 deletions(-) create mode 100644 app/src/main/java/com/ivor/openstream/data/local/dao/ProfileDao.kt create mode 100644 app/src/main/java/com/ivor/openstream/data/local/entity/ProfileEntity.kt create mode 100644 app/src/main/java/com/ivor/openstream/data/remote/model/CertificationDto.kt create mode 100644 app/src/main/java/com/ivor/openstream/data/repository/KidsContentFilter.kt create mode 100644 app/src/main/java/com/ivor/openstream/data/repository/ProfileRepository.kt create mode 100644 app/src/main/java/com/ivor/openstream/domain/model/Profile.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/profiles/ProfileAvatar.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/profiles/ProfileScreens.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/profiles/ProfilesViewModel.kt diff --git a/CLAUDE.md b/CLAUDE.md index 4d99b14..dca509e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,7 @@ Single activity (`MainActivity`), Navigation Compose, Hilt everywhere. ``` data/remote TMDB (TmdbApi), GitHub releases, DTOs -data/local Room: watch later, custom lists, downloads, watch progress, id mappings +data/local Room: profiles, watch later, custom lists, downloads, watch progress, id mappings data/repository Repository implementations (anime, downloads, progress, lists, OpenSubtitles) data/cast Cast options, media item converter, LAN proxy the receiver streams through data/streaming Source resolution, extension -> provider registry, id mapping @@ -56,7 +56,7 @@ Rules: - Composables render state and forward intent. ViewModels own screen state as `StateFlow`. Networking and persistence stay in `data/`. - Routes and arguments live in `presentation/navigation/AppNavigation.kt`. -- Room schema changes need a real `Migration` in `di/DatabaseModule.kt` (current version 7). +- Room schema changes need a real `Migration` in `di/DatabaseModule.kt` (current version 8). `fallbackToDestructiveMigration` is only a safety net; users' downloads and progress live there. ## How the main features work @@ -98,6 +98,13 @@ Rules: that entry (AniList title search only as a fallback), AniSkip v2 gives the intro/recap/credits times (anime only), and the player picks the submission timed on the closest file length. All keyless public APIs with no stability promise. +- **Profiles.** `profiles` table (seeded with profile 1); the active id is in `AppSettingsStore`. + Watch Later, progress, hidden titles and custom lists carry `profileId`; their DAOs take it and + the repositories follow the active id with `flatMapLatest`. Downloads are device-wide. + Switching profile stops playback. Kids profiles: `KidsContentFilter` adds TMDB certification + filters to discover calls and checks everything else against the US rating (G/PG, + TV-Y..TV-PG; unrated is hidden); Home hides Settings and leaving takes a hold on the avatar. + `include_adult=false` is added to every TMDB request. - **Lists.** Watch Later plus user lists (`CustomListRepository`, `custom_lists` tables); Details has "Add to list" and "Mark all watched", the Saved tab shows the lists. - **Backup / diagnostics.** `LibraryBackup` (JSON, merge on restore) and `Diagnostics` (crash files diff --git a/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt b/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt index 2398905..9cce9cb 100644 --- a/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt +++ b/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt @@ -9,6 +9,7 @@ import com.ivor.openstream.data.local.entity.CustomListItemEntity import com.ivor.openstream.data.local.entity.HiddenTitleEntity import com.ivor.openstream.data.local.entity.WatchLaterEntity import com.ivor.openstream.data.local.entity.WatchProgressEntity +import com.ivor.openstream.data.repository.ProfileRepository import com.ivor.openstream.data.settings.AppSettings import com.ivor.openstream.data.settings.AppSettingsStore import com.ivor.openstream.data.settings.DnsProvider @@ -27,16 +28,19 @@ data class RestoreSummary( val progress: Int, val hidden: Int, val settingsRestored: Boolean, - val lists: Int = 0 + val lists: Int = 0, + val profiles: Int = 0 ) class BackupFormatException(message: String) : Exception(message) /** - * The user's library as one JSON file: Watch Later, custom lists, watch progress (which is also - * History), hidden titles and app settings. Downloads aren't included; they're video files tied to this device. - * Restoring merges: nothing already on the device is deleted, progress keeps the newer row, and - * lists merge by name. + * The user's library as one JSON file: every profile with its Watch Later, custom lists, watch + * progress (which is also History) and hidden titles, plus app settings. Downloads aren't included; + * they're video files tied to this device. + * Restoring merges: nothing already on the device is deleted, profiles and lists merge by name, + * and progress keeps the newer row. A version 1 backup (from before profiles) goes into the + * active profile. */ @Singleton class LibraryBackup @Inject constructor( @@ -44,6 +48,7 @@ class LibraryBackup @Inject constructor( private val watchProgressDao: WatchProgressDao, private val hiddenTitleDao: HiddenTitleDao, private val customListDao: CustomListDao, + private val profiles: ProfileRepository, private val settingsStore: AppSettingsStore ) { private val json = Json { @@ -55,23 +60,34 @@ class LibraryBackup @Inject constructor( suspend fun export(output: OutputStream) { val backup = BackupFile( exportedAt = System.currentTimeMillis(), - watchLater = watchLaterDao.getAllWatchLaterItems().first().map { it.toBackup() }, - progress = watchProgressDao.observeAll().first().map { it.toBackup() }, - hidden = hiddenTitleDao.observeAll().first().map { it.toBackup() }, - lists = customListDao.allItems().groupBy { it.listId }.let { itemsByList -> - customListDao.allLists().map { list -> - BackupList( - name = list.name, - createdAt = list.createdAt, - items = itemsByList[list.id].orEmpty().map { it.toBackup() } - ) - } + profiles = profiles.all().map { profile -> + BackupProfile( + name = profile.name, + avatar = profile.avatar, + isKids = profile.isKids, + library = libraryOf(profile.id) + ) }, settings = settingsStore.current.toBackup() ) output.bufferedWriter().use { it.write(json.encodeToString(BackupFile.serializer(), backup)) } } + private suspend fun libraryOf(profileId: Long) = BackupLibrary( + watchLater = watchLaterDao.getAllWatchLaterItems(profileId).first().map { it.toBackup() }, + progress = watchProgressDao.observeAll(profileId).first().map { it.toBackup() }, + hidden = hiddenTitleDao.observeAll(profileId).first().map { it.toBackup() }, + lists = customListDao.allItems(profileId).groupBy { it.listId }.let { itemsByList -> + customListDao.allLists(profileId).map { list -> + BackupList( + name = list.name, + createdAt = list.createdAt, + items = itemsByList[list.id].orEmpty().map { it.toBackup() } + ) + } + } + ) + suspend fun restore(input: InputStream): RestoreSummary { val text = input.bufferedReader().use { it.readText() } val backup = runCatching { json.decodeFromString(BackupFile.serializer(), text) } @@ -79,35 +95,58 @@ class LibraryBackup @Inject constructor( if (backup.app != APP_ID) throw BackupFormatException("This isn't an OpenStream backup") if (backup.version > VERSION) throw BackupFormatException("This backup is from a newer version of OpenStream") - backup.watchLater.forEach { watchLaterDao.insertWatchLaterItem(it.toEntity()) } + var total = MergeCounts() + backup.profiles.forEach { profile -> + val profileId = profiles.findOrCreate(profile.name, profile.avatar, profile.isKids) + total += restoreLibrary(profile.library, profileId) + } + // Version 1 kept one library at the top level: it belongs to whoever is restoring. + val legacy = BackupLibrary(backup.watchLater, backup.progress, backup.hidden, backup.lists) + if (!legacy.isEmpty()) total += restoreLibrary(legacy, settingsStore.activeProfileId.value) + backup.settings?.let { saved -> settingsStore.update { saved.applyTo(it) } } + + return RestoreSummary( + watchLater = total.watchLater, + progress = total.progress, + hidden = total.hidden, + settingsRestored = backup.settings != null, + lists = total.lists, + profiles = backup.profiles.size + ) + } + + private suspend fun restoreLibrary(library: BackupLibrary, profileId: Long): MergeCounts { + library.watchLater.forEach { watchLaterDao.insertWatchLaterItem(it.toEntity(profileId)) } var progressAdded = 0 - backup.progress.forEach { row -> - val existing = watchProgressDao.get(row.id) + library.progress.forEach { row -> + val existing = watchProgressDao.get(profileId, row.id) if (existing == null || existing.updatedAt < row.updatedAt) { - watchProgressDao.upsert(row.toEntity()) + watchProgressDao.upsert(row.toEntity(profileId)) progressAdded++ } } - backup.hidden.forEach { hiddenTitleDao.insert(it.toEntity()) } - backup.lists.forEach { list -> - val listId = customListDao.findByName(list.name) - ?: customListDao.insertList(CustomListEntity(name = list.name, createdAt = list.createdAt)) + library.hidden.forEach { hiddenTitleDao.insert(it.toEntity(profileId)) } + library.lists.forEach { list -> + val listId = customListDao.findByName(profileId, list.name) + ?: customListDao.insertList(CustomListEntity(name = list.name, createdAt = list.createdAt, profileId = profileId)) list.items.forEach { customListDao.addItem(it.toEntity(listId)) } } - backup.settings?.let { saved -> settingsStore.update { saved.applyTo(it) } } + return MergeCounts(library.watchLater.size, progressAdded, library.hidden.size, library.lists.size) + } - return RestoreSummary( - watchLater = backup.watchLater.size, - progress = progressAdded, - hidden = backup.hidden.size, - settingsRestored = backup.settings != null, - lists = backup.lists.size + private data class MergeCounts(val watchLater: Int = 0, val progress: Int = 0, val hidden: Int = 0, val lists: Int = 0) { + operator fun plus(other: MergeCounts) = MergeCounts( + watchLater + other.watchLater, + progress + other.progress, + hidden + other.hidden, + lists + other.lists ) } companion object { const val APP_ID = "openstream" - const val VERSION = 1 + /** 2 added profiles; 1 had a single top-level library. */ + const val VERSION = 2 const val MIME_TYPE = "application/json" } } @@ -117,6 +156,8 @@ private data class BackupFile( val app: String = LibraryBackup.APP_ID, val version: Int = LibraryBackup.VERSION, val exportedAt: Long = 0L, + val profiles: List = emptyList(), + // Version 1: one library, no profiles. val watchLater: List = emptyList(), val progress: List = emptyList(), val hidden: List = emptyList(), @@ -124,6 +165,24 @@ private data class BackupFile( val settings: BackupSettings? = null ) +@Serializable +private data class BackupProfile( + val name: String, + val avatar: String = ProfileRepository.DEFAULT_AVATAR, + val isKids: Boolean = false, + val library: BackupLibrary = BackupLibrary() +) + +@Serializable +private data class BackupLibrary( + val watchLater: List = emptyList(), + val progress: List = emptyList(), + val hidden: List = emptyList(), + val lists: List = emptyList() +) { + fun isEmpty() = watchLater.isEmpty() && progress.isEmpty() && hidden.isEmpty() && lists.isEmpty() +} + @Serializable private data class BackupList( val name: String, @@ -154,7 +213,7 @@ private data class BackupWatchLater( val voteAverage: Double = 0.0, val dateAdded: Long = 0L ) { - fun toEntity() = WatchLaterEntity(id, title, posterPath, mediaType, voteAverage, dateAdded) + fun toEntity(profileId: Long) = WatchLaterEntity(id, title, posterPath, mediaType, voteAverage, dateAdded, profileId) } private fun WatchLaterEntity.toBackup() = BackupWatchLater(id, title, posterPath, mediaType, voteAverage, dateAdded) @@ -176,9 +235,9 @@ private data class BackupProgress( val completed: Boolean, val updatedAt: Long ) { - fun toEntity() = WatchProgressEntity( + fun toEntity(profileId: Long) = WatchProgressEntity( id, tmdbId, mediaType, season, episode, title, episodeTitle, posterPath, backdropPath, - stillPath, positionMs, durationMs, completed, updatedAt + stillPath, positionMs, durationMs, completed, updatedAt, profileId ) } @@ -189,7 +248,7 @@ private fun WatchProgressEntity.toBackup() = BackupProgress( @Serializable private data class BackupHidden(val tmdbId: Int, val mediaType: String, val title: String, val hiddenAt: Long = 0L) { - fun toEntity() = HiddenTitleEntity(tmdbId, mediaType, title, hiddenAt) + fun toEntity(profileId: Long) = HiddenTitleEntity(tmdbId, mediaType, title, hiddenAt, profileId) } private fun HiddenTitleEntity.toBackup() = BackupHidden(tmdbId, mediaType, title, hiddenAt) diff --git a/app/src/main/java/com/ivor/openstream/data/local/AppDatabase.kt b/app/src/main/java/com/ivor/openstream/data/local/AppDatabase.kt index 96bdcec..9fa92fa 100644 --- a/app/src/main/java/com/ivor/openstream/data/local/AppDatabase.kt +++ b/app/src/main/java/com/ivor/openstream/data/local/AppDatabase.kt @@ -7,12 +7,14 @@ import com.ivor.openstream.data.local.dao.DownloadDao import com.ivor.openstream.data.local.dao.HiddenTitleDao import com.ivor.openstream.data.local.dao.WatchLaterDao import com.ivor.openstream.data.local.dao.IdMappingDao +import com.ivor.openstream.data.local.dao.ProfileDao import com.ivor.openstream.data.local.dao.WatchProgressDao import com.ivor.openstream.data.local.entity.CustomListEntity import com.ivor.openstream.data.local.entity.CustomListItemEntity import com.ivor.openstream.data.local.entity.DownloadEntity import com.ivor.openstream.data.local.entity.HiddenTitleEntity import com.ivor.openstream.data.local.entity.IdMappingEntity +import com.ivor.openstream.data.local.entity.ProfileEntity import com.ivor.openstream.data.local.entity.WatchLaterEntity import com.ivor.openstream.data.local.entity.WatchProgressEntity @@ -24,9 +26,10 @@ import com.ivor.openstream.data.local.entity.WatchProgressEntity WatchProgressEntity::class, HiddenTitleEntity::class, CustomListEntity::class, - CustomListItemEntity::class + CustomListItemEntity::class, + ProfileEntity::class ], - version = 7, + version = 8, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { @@ -36,4 +39,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun watchProgressDao(): WatchProgressDao abstract fun hiddenTitleDao(): HiddenTitleDao abstract fun customListDao(): CustomListDao + abstract fun profileDao(): ProfileDao } diff --git a/app/src/main/java/com/ivor/openstream/data/local/dao/CustomListDao.kt b/app/src/main/java/com/ivor/openstream/data/local/dao/CustomListDao.kt index 9052993..92f98a6 100644 --- a/app/src/main/java/com/ivor/openstream/data/local/dao/CustomListDao.kt +++ b/app/src/main/java/com/ivor/openstream/data/local/dao/CustomListDao.kt @@ -31,26 +31,32 @@ interface CustomListDao { ORDER BY i2.addedAt DESC LIMIT 4 )) AS posters FROM custom_lists l + WHERE l.profileId = :profileId ORDER BY l.updatedAt DESC """ ) - fun observeSummaries(): Flow> + fun observeSummaries(profileId: Long): Flow> @Query("SELECT * FROM custom_lists WHERE id = :listId") fun observeList(listId: Long): Flow - @Query("SELECT * FROM custom_lists ORDER BY updatedAt DESC") - suspend fun allLists(): List + @Query("SELECT * FROM custom_lists WHERE profileId = :profileId ORDER BY updatedAt DESC") + suspend fun allLists(profileId: Long): List @Query("SELECT * FROM custom_list_items WHERE listId = :listId ORDER BY addedAt DESC") fun observeItems(listId: Long): Flow> - @Query("SELECT * FROM custom_list_items") - suspend fun allItems(): List + @Query( + "SELECT i.* FROM custom_list_items i JOIN custom_lists l ON l.id = i.listId WHERE l.profileId = :profileId" + ) + suspend fun allItems(profileId: Long): List - /** Ids of the lists that contain a title. */ - @Query("SELECT listId FROM custom_list_items WHERE mediaType = :mediaType AND tmdbId = :tmdbId") - fun observeListIdsFor(mediaType: String, tmdbId: Int): Flow> + /** Ids of the profile's lists that contain a title. */ + @Query( + "SELECT i.listId FROM custom_list_items i JOIN custom_lists l ON l.id = i.listId " + + "WHERE l.profileId = :profileId AND i.mediaType = :mediaType AND i.tmdbId = :tmdbId" + ) + fun observeListIdsFor(profileId: Long, mediaType: String, tmdbId: Int): Flow> @Insert suspend fun insertList(list: CustomListEntity): Long @@ -85,6 +91,6 @@ interface CustomListDao { @Query("DELETE FROM custom_list_items WHERE listId = :listId AND mediaType = :mediaType AND tmdbId = :tmdbId") suspend fun removeItem(listId: Long, mediaType: String, tmdbId: Int) - @Query("SELECT id FROM custom_lists WHERE name = :name COLLATE NOCASE LIMIT 1") - suspend fun findByName(name: String): Long? + @Query("SELECT id FROM custom_lists WHERE profileId = :profileId AND name = :name COLLATE NOCASE LIMIT 1") + suspend fun findByName(profileId: Long, name: String): Long? } diff --git a/app/src/main/java/com/ivor/openstream/data/local/dao/HiddenTitleDao.kt b/app/src/main/java/com/ivor/openstream/data/local/dao/HiddenTitleDao.kt index 361bda6..376e5aa 100644 --- a/app/src/main/java/com/ivor/openstream/data/local/dao/HiddenTitleDao.kt +++ b/app/src/main/java/com/ivor/openstream/data/local/dao/HiddenTitleDao.kt @@ -7,17 +7,18 @@ import androidx.room.Query import com.ivor.openstream.data.local.entity.HiddenTitleEntity import kotlinx.coroutines.flow.Flow +/** Every query is scoped to one profile. */ @Dao interface HiddenTitleDao { - @Query("SELECT * FROM hidden_titles ORDER BY hiddenAt DESC") - fun observeAll(): Flow> + @Query("SELECT * FROM hidden_titles WHERE profileId = :profileId ORDER BY hiddenAt DESC") + fun observeAll(profileId: Long): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(title: HiddenTitleEntity) - @Query("DELETE FROM hidden_titles WHERE mediaType = :mediaType AND tmdbId = :tmdbId") - suspend fun delete(mediaType: String, tmdbId: Int) + @Query("DELETE FROM hidden_titles WHERE profileId = :profileId AND mediaType = :mediaType AND tmdbId = :tmdbId") + suspend fun delete(profileId: Long, mediaType: String, tmdbId: Int) - @Query("DELETE FROM hidden_titles") - suspend fun clear() + @Query("DELETE FROM hidden_titles WHERE profileId = :profileId") + suspend fun clear(profileId: Long) } diff --git a/app/src/main/java/com/ivor/openstream/data/local/dao/ProfileDao.kt b/app/src/main/java/com/ivor/openstream/data/local/dao/ProfileDao.kt new file mode 100644 index 0000000..7457a73 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/local/dao/ProfileDao.kt @@ -0,0 +1,58 @@ +package com.ivor.openstream.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Transaction +import com.ivor.openstream.data.local.entity.ProfileEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ProfileDao { + @Query("SELECT * FROM profiles ORDER BY createdAt, id") + fun observeAll(): Flow> + + @Query("SELECT * FROM profiles ORDER BY createdAt, id") + suspend fun all(): List + + @Query("SELECT * FROM profiles WHERE id = :id") + suspend fun get(id: Long): ProfileEntity? + + @Query("SELECT id FROM profiles WHERE name = :name COLLATE NOCASE LIMIT 1") + suspend fun findByName(name: String): Long? + + @Insert + suspend fun insert(profile: ProfileEntity): Long + + @Query("UPDATE profiles SET name = :name, avatar = :avatar, isKids = :isKids WHERE id = :id") + suspend fun update(id: Long, name: String, avatar: String, isKids: Boolean) + + @Query("DELETE FROM profiles WHERE id = :id") + suspend fun deleteRow(id: Long) + + @Query("DELETE FROM watch_later WHERE profileId = :id") + suspend fun deleteWatchLater(id: Long) + + @Query("DELETE FROM watch_progress WHERE profileId = :id") + suspend fun deleteProgress(id: Long) + + @Query("DELETE FROM hidden_titles WHERE profileId = :id") + suspend fun deleteHidden(id: Long) + + @Query("DELETE FROM custom_list_items WHERE listId IN (SELECT id FROM custom_lists WHERE profileId = :id)") + suspend fun deleteListItems(id: Long) + + @Query("DELETE FROM custom_lists WHERE profileId = :id") + suspend fun deleteLists(id: Long) + + /** Deletes a profile and everything that belonged to it (downloads are device-wide and stay). */ + @Transaction + suspend fun deleteWithData(id: Long) { + deleteWatchLater(id) + deleteProgress(id) + deleteHidden(id) + deleteListItems(id) + deleteLists(id) + deleteRow(id) + } +} diff --git a/app/src/main/java/com/ivor/openstream/data/local/dao/WatchLaterDao.kt b/app/src/main/java/com/ivor/openstream/data/local/dao/WatchLaterDao.kt index 01327b0..3db7eac 100644 --- a/app/src/main/java/com/ivor/openstream/data/local/dao/WatchLaterDao.kt +++ b/app/src/main/java/com/ivor/openstream/data/local/dao/WatchLaterDao.kt @@ -1,27 +1,24 @@ package com.ivor.openstream.data.local.dao import androidx.room.Dao -import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.ivor.openstream.data.local.entity.WatchLaterEntity import kotlinx.coroutines.flow.Flow +/** Every query is scoped to one profile. */ @Dao interface WatchLaterDao { - @Query("SELECT * FROM watch_later ORDER BY dateAdded DESC") - fun getAllWatchLaterItems(): Flow> + @Query("SELECT * FROM watch_later WHERE profileId = :profileId ORDER BY dateAdded DESC") + fun getAllWatchLaterItems(profileId: Long): Flow> - @Query("SELECT EXISTS(SELECT 1 FROM watch_later WHERE id = :id)") - fun isWatchLater(id: Int): Flow + @Query("SELECT EXISTS(SELECT 1 FROM watch_later WHERE profileId = :profileId AND id = :id)") + fun isWatchLater(profileId: Long, id: Int): Flow @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertWatchLaterItem(item: WatchLaterEntity) - @Delete - suspend fun deleteWatchLaterItem(item: WatchLaterEntity) - - @Query("DELETE FROM watch_later WHERE id = :id") - suspend fun deleteWatchLaterItemById(id: Int) + @Query("DELETE FROM watch_later WHERE profileId = :profileId AND id = :id") + suspend fun deleteWatchLaterItemById(profileId: Long, id: Int) } diff --git a/app/src/main/java/com/ivor/openstream/data/local/dao/WatchProgressDao.kt b/app/src/main/java/com/ivor/openstream/data/local/dao/WatchProgressDao.kt index c7e6027..a2ab0b0 100644 --- a/app/src/main/java/com/ivor/openstream/data/local/dao/WatchProgressDao.kt +++ b/app/src/main/java/com/ivor/openstream/data/local/dao/WatchProgressDao.kt @@ -7,45 +7,53 @@ import androidx.room.Query import com.ivor.openstream.data.local.entity.WatchProgressEntity import kotlinx.coroutines.flow.Flow +/** Every query is scoped to one profile. */ @Dao interface WatchProgressDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(progress: WatchProgressEntity) - @Query("SELECT * FROM watch_progress WHERE id = :id") - suspend fun get(id: String): WatchProgressEntity? + @Query("SELECT * FROM watch_progress WHERE profileId = :profileId AND id = :id") + suspend fun get(profileId: Long, id: String): WatchProgressEntity? /** The most recently touched unfinished entry of every title, newest first. */ @Query( """ SELECT * FROM watch_progress AS p - WHERE p.completed = 0 + WHERE p.profileId = :profileId + AND p.completed = 0 AND p.updatedAt = ( SELECT MAX(q.updatedAt) FROM watch_progress AS q - WHERE q.tmdbId = p.tmdbId AND q.mediaType = p.mediaType + WHERE q.profileId = p.profileId AND q.tmdbId = p.tmdbId AND q.mediaType = p.mediaType ) ORDER BY p.updatedAt DESC LIMIT :limit """ ) - fun observeContinueWatching(limit: Int): Flow> + fun observeContinueWatching(profileId: Long, limit: Int): Flow> - @Query("SELECT * FROM watch_progress ORDER BY updatedAt DESC") - fun observeAll(): Flow> + @Query("SELECT * FROM watch_progress WHERE profileId = :profileId ORDER BY updatedAt DESC") + fun observeAll(profileId: Long): Flow> - @Query("SELECT * FROM watch_progress WHERE mediaType = :mediaType AND tmdbId = :tmdbId ORDER BY updatedAt DESC") - fun observeForTitle(mediaType: String, tmdbId: Int): Flow> + @Query( + "SELECT * FROM watch_progress WHERE profileId = :profileId AND mediaType = :mediaType " + + "AND tmdbId = :tmdbId ORDER BY updatedAt DESC" + ) + fun observeForTitle(profileId: Long, mediaType: String, tmdbId: Int): Flow> /** Drops in-progress rows but keeps finished ones, so watched marks survive. */ - @Query("DELETE FROM watch_progress WHERE mediaType = :mediaType AND tmdbId = :tmdbId AND completed = 0") - suspend fun deleteUnfinished(mediaType: String, tmdbId: Int) + @Query( + "DELETE FROM watch_progress WHERE profileId = :profileId AND mediaType = :mediaType " + + "AND tmdbId = :tmdbId AND completed = 0" + ) + suspend fun deleteUnfinished(profileId: Long, mediaType: String, tmdbId: Int) - @Query("DELETE FROM watch_progress WHERE mediaType = :mediaType AND tmdbId = :tmdbId") - suspend fun deleteForTitle(mediaType: String, tmdbId: Int) + @Query("DELETE FROM watch_progress WHERE profileId = :profileId AND mediaType = :mediaType AND tmdbId = :tmdbId") + suspend fun deleteForTitle(profileId: Long, mediaType: String, tmdbId: Int) - @Query("DELETE FROM watch_progress WHERE id = :id") - suspend fun delete(id: String) + @Query("DELETE FROM watch_progress WHERE profileId = :profileId AND id = :id") + suspend fun delete(profileId: Long, id: String) - @Query("DELETE FROM watch_progress") - suspend fun clear() + @Query("DELETE FROM watch_progress WHERE profileId = :profileId") + suspend fun clear(profileId: Long) } diff --git a/app/src/main/java/com/ivor/openstream/data/local/entity/CustomListEntity.kt b/app/src/main/java/com/ivor/openstream/data/local/entity/CustomListEntity.kt index 71f6fd1..bf6228e 100644 --- a/app/src/main/java/com/ivor/openstream/data/local/entity/CustomListEntity.kt +++ b/app/src/main/java/com/ivor/openstream/data/local/entity/CustomListEntity.kt @@ -4,13 +4,14 @@ import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey -/** A list the user made (beyond Watch Later), e.g. "Weekend movies". */ -@Entity(tableName = "custom_lists") +/** A list a profile made (beyond Watch Later), e.g. "Weekend movies". Items follow via listId. */ +@Entity(tableName = "custom_lists", indices = [Index(value = ["profileId"])]) data class CustomListEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val name: String, val createdAt: Long = System.currentTimeMillis(), - val updatedAt: Long = System.currentTimeMillis() + val updatedAt: Long = System.currentTimeMillis(), + val profileId: Long = ProfileEntity.DEFAULT_ID ) /** A title in a custom list. Deleting a list deletes its items (see CustomListDao.deleteList). */ diff --git a/app/src/main/java/com/ivor/openstream/data/local/entity/HiddenTitleEntity.kt b/app/src/main/java/com/ivor/openstream/data/local/entity/HiddenTitleEntity.kt index 30a3f14..781e09e 100644 --- a/app/src/main/java/com/ivor/openstream/data/local/entity/HiddenTitleEntity.kt +++ b/app/src/main/java/com/ivor/openstream/data/local/entity/HiddenTitleEntity.kt @@ -3,10 +3,11 @@ package com.ivor.openstream.data.local.entity import androidx.room.Entity /** A title the user marked "Not interested"; it's left out of Home rows. */ -@Entity(tableName = "hidden_titles", primaryKeys = ["mediaType", "tmdbId"]) +@Entity(tableName = "hidden_titles", primaryKeys = ["profileId", "mediaType", "tmdbId"]) data class HiddenTitleEntity( val tmdbId: Int, val mediaType: String, val title: String, - val hiddenAt: Long = System.currentTimeMillis() + val hiddenAt: Long = System.currentTimeMillis(), + val profileId: Long = ProfileEntity.DEFAULT_ID ) diff --git a/app/src/main/java/com/ivor/openstream/data/local/entity/ProfileEntity.kt b/app/src/main/java/com/ivor/openstream/data/local/entity/ProfileEntity.kt new file mode 100644 index 0000000..a498f6c --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/local/entity/ProfileEntity.kt @@ -0,0 +1,24 @@ +package com.ivor.openstream.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Someone who watches on this device. Watch Later, progress (History, Continue Watching), hidden + * titles and custom lists belong to a profile; downloads are shared by the whole device. + * + * [avatar] is a built-in avatar key (see `ProfileAvatar`). + */ +@Entity(tableName = "profiles") +data class ProfileEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + val avatar: String, + val isKids: Boolean, + val createdAt: Long = System.currentTimeMillis() +) { + companion object { + /** The profile every row that predates profiles belongs to (seeded by the migration). */ + const val DEFAULT_ID = 1L + } +} diff --git a/app/src/main/java/com/ivor/openstream/data/local/entity/WatchLaterEntity.kt b/app/src/main/java/com/ivor/openstream/data/local/entity/WatchLaterEntity.kt index b5b863e..8b306b2 100644 --- a/app/src/main/java/com/ivor/openstream/data/local/entity/WatchLaterEntity.kt +++ b/app/src/main/java/com/ivor/openstream/data/local/entity/WatchLaterEntity.kt @@ -1,15 +1,15 @@ package com.ivor.openstream.data.local.entity import androidx.room.Entity -import androidx.room.PrimaryKey -@Entity(tableName = "watch_later") +/** A title a profile saved for later; [id] is the TMDB id. */ +@Entity(tableName = "watch_later", primaryKeys = ["profileId", "id"]) data class WatchLaterEntity( - @PrimaryKey val id: Int, val title: String, val posterPath: String?, val mediaType: String, val voteAverage: Double, - val dateAdded: Long = System.currentTimeMillis() + val dateAdded: Long = System.currentTimeMillis(), + val profileId: Long = ProfileEntity.DEFAULT_ID ) diff --git a/app/src/main/java/com/ivor/openstream/data/local/entity/WatchProgressEntity.kt b/app/src/main/java/com/ivor/openstream/data/local/entity/WatchProgressEntity.kt index 8f74d5a..c54ca2a 100644 --- a/app/src/main/java/com/ivor/openstream/data/local/entity/WatchProgressEntity.kt +++ b/app/src/main/java/com/ivor/openstream/data/local/entity/WatchProgressEntity.kt @@ -2,7 +2,6 @@ package com.ivor.openstream.data.local.entity import androidx.room.Entity import androidx.room.Index -import androidx.room.PrimaryKey /** * One row per episode (or movie) the user has started. Rows with `positionMs == 0` and @@ -10,10 +9,10 @@ import androidx.room.PrimaryKey */ @Entity( tableName = "watch_progress", - indices = [Index(value = ["mediaType", "tmdbId"])] + primaryKeys = ["profileId", "id"], + indices = [Index(value = ["profileId", "mediaType", "tmdbId"])] ) data class WatchProgressEntity( - @PrimaryKey val id: String, val tmdbId: Int, val mediaType: String, @@ -27,7 +26,8 @@ data class WatchProgressEntity( val positionMs: Long, val durationMs: Long, val completed: Boolean, - val updatedAt: Long + val updatedAt: Long, + val profileId: Long = ProfileEntity.DEFAULT_ID ) { companion object { fun idFor(mediaType: String, tmdbId: Int, season: Int, episode: Int): String = diff --git a/app/src/main/java/com/ivor/openstream/data/remote/TmdbApi.kt b/app/src/main/java/com/ivor/openstream/data/remote/TmdbApi.kt index 767baf3..95ee8ed 100644 --- a/app/src/main/java/com/ivor/openstream/data/remote/TmdbApi.kt +++ b/app/src/main/java/com/ivor/openstream/data/remote/TmdbApi.kt @@ -7,12 +7,22 @@ import com.ivor.openstream.data.remote.model.TmdbResponse import com.ivor.openstream.data.remote.model.KeywordDto import com.ivor.openstream.data.remote.model.ExternalIdsDto import com.ivor.openstream.data.remote.model.PersonDto +import com.ivor.openstream.data.remote.model.ContentRatingsDto +import com.ivor.openstream.data.remote.model.ReleaseDatesDto import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.Query import retrofit2.http.QueryMap interface TmdbApi { + /** Per-country certifications (US "PG", "R"...) for a movie. */ + @GET("movie/{id}/release_dates") + suspend fun getMovieReleaseDates(@Path("id") id: Int): ReleaseDatesDto + + /** Per-country TV ratings (US "TV-Y7", "TV-MA"...) for a show. */ + @GET("tv/{id}/content_ratings") + suspend fun getTvContentRatings(@Path("id") id: Int): ContentRatingsDto + @GET("{media_type}/{id}/external_ids") suspend fun getExternalIds( @Path("media_type") mediaType: String, diff --git a/app/src/main/java/com/ivor/openstream/data/remote/model/AnimeDto.kt b/app/src/main/java/com/ivor/openstream/data/remote/model/AnimeDto.kt index 9c0691d..6a3cb2a 100644 --- a/app/src/main/java/com/ivor/openstream/data/remote/model/AnimeDto.kt +++ b/app/src/main/java/com/ivor/openstream/data/remote/model/AnimeDto.kt @@ -17,7 +17,8 @@ data class AnimeDto( @SerialName("genre_ids") val genreIds: List? = null, @SerialName("media_type") val mediaType: String? = "tv", @SerialName("original_language") val originalLanguage: String? = null, - @SerialName("popularity") val popularity: Double? = null + @SerialName("popularity") val popularity: Double? = null, + @SerialName("adult") val adult: Boolean? = null ) { val name: String get() = movieTitle ?: tvName ?: "" diff --git a/app/src/main/java/com/ivor/openstream/data/remote/model/CertificationDto.kt b/app/src/main/java/com/ivor/openstream/data/remote/model/CertificationDto.kt new file mode 100644 index 0000000..8e98d2c --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/remote/model/CertificationDto.kt @@ -0,0 +1,25 @@ +package com.ivor.openstream.data.remote.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ReleaseDatesDto(val results: List = emptyList()) + +@Serializable +data class CountryReleaseDates( + @SerialName("iso_3166_1") val country: String = "", + @SerialName("release_dates") val releaseDates: List = emptyList() +) + +@Serializable +data class ReleaseDateDto(val certification: String = "") + +@Serializable +data class ContentRatingsDto(val results: List = emptyList()) + +@Serializable +data class ContentRatingDto( + @SerialName("iso_3166_1") val country: String = "", + val rating: String = "" +) diff --git a/app/src/main/java/com/ivor/openstream/data/repository/AnimeRepositoryImpl.kt b/app/src/main/java/com/ivor/openstream/data/repository/AnimeRepositoryImpl.kt index 423f9ef..a876f84 100644 --- a/app/src/main/java/com/ivor/openstream/data/repository/AnimeRepositoryImpl.kt +++ b/app/src/main/java/com/ivor/openstream/data/repository/AnimeRepositoryImpl.kt @@ -18,7 +18,8 @@ import javax.inject.Inject class AnimeRepositoryImpl @Inject constructor( private val api: TmdbApi, private val sharedPreferences: SharedPreferences, - private val json: Json + private val json: Json, + private val kids: KidsContentFilter ) : AnimeRepository { private val HISTORY_KEY = "watch_history_list" @@ -40,24 +41,27 @@ class AnimeRepositoryImpl @Inject constructor( } override suspend fun getCatalog(catalog: AnimeCatalog, forceRefresh: Boolean): Result> { - val cached = catalogCache[catalog] ?: readCachedCatalog(catalog)?.also { catalogCache[catalog] = it } + // Kids profiles get their own filtered copy of each list. + val key = catalogKey(catalog) + val cached = catalogCache[key] ?: readCachedCatalog(key)?.also { catalogCache[key] = it } val isFresh = cached != null && System.currentTimeMillis() - cached.savedAt < CATALOG_TTL_MS if (!forceRefresh && isFresh) return Result.success(cached!!.items) return fetchCatalog(catalog) .onSuccess { items -> val entry = CachedCatalog(System.currentTimeMillis(), items) - catalogCache[catalog] = entry - sharedPreferences.edit().putString(catalogKey(catalog), json.encodeToString(entry)).apply() + catalogCache[key] = entry + sharedPreferences.edit().putString(key, json.encodeToString(entry)).apply() } // Offline or rate limited: an older list beats an empty Home. .recoverCatching { error -> cached?.items ?: throw error } } - private fun readCachedCatalog(catalog: AnimeCatalog): CachedCatalog? = runCatching { - sharedPreferences.getString(catalogKey(catalog), null)?.let { json.decodeFromString(it) } + private fun readCachedCatalog(key: String): CachedCatalog? = runCatching { + sharedPreferences.getString(key, null)?.let { json.decodeFromString(it) } }.getOrNull() - private fun catalogKey(catalog: AnimeCatalog) = "catalog_cache_${catalog.name}" + private fun catalogKey(catalog: AnimeCatalog) = + if (kids.isActive) "catalog_cache_kids_${catalog.name}" else "catalog_cache_${catalog.name}" private suspend fun fetchCatalog(catalog: AnimeCatalog): Result> = runCatching { val anime = mapOf( @@ -65,19 +69,21 @@ class AnimeRepositoryImpl @Inject constructor( "with_original_language" to "ja", "include_adult" to "false" ) + val movieKids = kids.movieDiscoverParams() + val tvKids = kids.tvDiscoverParams() val results = when (catalog) { AnimeCatalog.TRENDING -> api.getTrendingAll("week").results // The mixed feed also lists people. .filter { it.mediaType == "movie" || it.mediaType == "tv" } AnimeCatalog.NEW_EPISODES -> api.getOnTheAir().results AnimeCatalog.POPULAR_MOVIES -> api.discoverMovieWith( - mapOf("sort_by" to "popularity.desc", "vote_count.gte" to "300", "include_adult" to "false") + mapOf("sort_by" to "popularity.desc", "vote_count.gte" to "300", "include_adult" to "false") + movieKids ).results.map { it.copy(mediaType = "movie") } AnimeCatalog.POPULAR_SERIES -> api.discoverTvWith( - mapOf("sort_by" to "popularity.desc", "vote_count.gte" to "200", "include_adult" to "false") - ).results + mapOf("sort_by" to "popularity.desc", "vote_count.gte" to "200", "include_adult" to "false") + tvKids + ).results.map { it.copy(mediaType = "tv") } AnimeCatalog.TOP_RATED_MOVIES -> api.discoverMovieWith( - mapOf("sort_by" to "vote_average.desc", "vote_count.gte" to "3000", "include_adult" to "false") + mapOf("sort_by" to "vote_average.desc", "vote_count.gte" to "3000", "include_adult" to "false") + movieKids ).results.map { it.copy(mediaType = "movie") } AnimeCatalog.TRENDING_ANIME -> coroutineScope { // TMDB's trending feed cannot be filtered server-side, so read a few pages and keep anime. @@ -85,12 +91,17 @@ class AnimeRepositoryImpl @Inject constructor( .awaitAll() .flatten() .filter { it.originalLanguage == "ja" && it.genreIds.orEmpty().contains(ANIMATION_GENRE) } - }.ifEmpty { api.discoverTvWith(anime + ("sort_by" to "popularity.desc")).results } + }.ifEmpty { api.discoverTvWith(anime + ("sort_by" to "popularity.desc") + tvKids).results } AnimeCatalog.ANIME_MOVIES -> api.discoverMovieWith( - anime + mapOf("sort_by" to "popularity.desc", "vote_count.gte" to "100") + anime + mapOf("sort_by" to "popularity.desc", "vote_count.gte" to "100") + movieKids ).results.map { it.copy(mediaType = "movie") } } - results.filter { it.posterPath != null }.distinctBy { "${it.mediaType}:${it.id}" } + val moviesPreFiltered = catalog == AnimeCatalog.POPULAR_MOVIES || catalog == AnimeCatalog.TOP_RATED_MOVIES || + catalog == AnimeCatalog.ANIME_MOVIES + kids.filter( + results.filter { it.posterPath != null }.distinctBy { "${it.mediaType}:${it.id}" }, + moviesPreFiltered = moviesPreFiltered + ) } override suspend fun discoverByGenre(genre: BrowseGenre, page: Int): Result> = runCatching { @@ -99,19 +110,20 @@ class AnimeRepositoryImpl @Inject constructor( if (genre.isAnime) mapOf("with_original_language" to "ja") else emptyMap() val movies = genre.movieGenreId?.let { id -> async { - api.discoverMovieWith(common + mapOf("with_genres" to "$id", "vote_count.gte" to "50")) + api.discoverMovieWith(common + mapOf("with_genres" to "$id", "vote_count.gte" to "50") + kids.movieDiscoverParams()) .results.map { it.copy(mediaType = "movie") } } } val series = genre.tvGenreId?.let { id -> async { - api.discoverTvWith(common + mapOf("with_genres" to "$id", "vote_count.gte" to "30")) + api.discoverTvWith(common + mapOf("with_genres" to "$id", "vote_count.gte" to "30") + kids.tvDiscoverParams()) .results.map { it.copy(mediaType = "tv") } } } - (movies?.await().orEmpty() + series?.await().orEmpty()) + val combined = (movies?.await().orEmpty() + series?.await().orEmpty()) .filter { it.posterPath != null } .sortedByDescending { it.popularity ?: 0.0 } + kids.filter(combined, moviesPreFiltered = true) } } @@ -135,11 +147,13 @@ class AnimeRepositoryImpl @Inject constructor( } } - AnimeSearchResults.prepare( - tvShows = tvShows, - movies = movies, - sortBy = sortBy, - query = query + kids.filter( + AnimeSearchResults.prepare( + tvShows = tvShows, + movies = movies, + sortBy = sortBy, + query = query + ) ) } @@ -186,7 +200,7 @@ class AnimeRepositoryImpl @Inject constructor( sharedPreferences.edit().remove(HISTORY_KEY).apply() } - private val catalogCache = java.util.concurrent.ConcurrentHashMap() + private val catalogCache = java.util.concurrent.ConcurrentHashMap() @kotlinx.serialization.Serializable private data class CachedCatalog(val savedAt: Long, val items: List) diff --git a/app/src/main/java/com/ivor/openstream/data/repository/CustomListRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/CustomListRepository.kt index 86970f4..c5ca67a 100644 --- a/app/src/main/java/com/ivor/openstream/data/repository/CustomListRepository.kt +++ b/app/src/main/java/com/ivor/openstream/data/repository/CustomListRepository.kt @@ -4,16 +4,24 @@ import com.ivor.openstream.data.local.dao.CustomListDao import com.ivor.openstream.data.local.dao.CustomListSummary import com.ivor.openstream.data.local.entity.CustomListEntity import com.ivor.openstream.data.local.entity.CustomListItemEntity +import com.ivor.openstream.data.settings.AppSettingsStore +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton -/** The user's own lists of titles, kept alongside (not instead of) Watch Later. */ +/** The active profile's own lists of titles, kept alongside (not instead of) Watch Later. */ +@OptIn(ExperimentalCoroutinesApi::class) @Singleton -class CustomListRepository @Inject constructor(private val dao: CustomListDao) { +class CustomListRepository @Inject constructor( + private val dao: CustomListDao, + private val settings: AppSettingsStore +) { + private val profileId: Long get() = settings.activeProfileId.value - fun summaries(): Flow> = dao.observeSummaries() + fun summaries(): Flow> = settings.activeProfileId.flatMapLatest { dao.observeSummaries(it) } fun list(listId: Long): Flow = dao.observeList(listId) @@ -21,12 +29,12 @@ class CustomListRepository @Inject constructor(private val dao: CustomListDao) { /** Ids of the lists a title is in. */ fun listIdsFor(mediaType: String, tmdbId: Int): Flow> = - dao.observeListIdsFor(mediaType, tmdbId).map { it.toSet() } + settings.activeProfileId.flatMapLatest { dao.observeListIdsFor(it, mediaType, tmdbId) }.map { it.toSet() } /** Creates a list; a name that already exists (ignoring case) returns that list instead. */ suspend fun create(name: String): Long { val trimmed = name.trim() - return dao.findByName(trimmed) ?: dao.insertList(CustomListEntity(name = trimmed)) + return dao.findByName(profileId, trimmed) ?: dao.insertList(CustomListEntity(name = trimmed, profileId = profileId)) } suspend fun rename(listId: Long, name: String) = dao.rename(listId, name.trim()) diff --git a/app/src/main/java/com/ivor/openstream/data/repository/HiddenTitlesRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/HiddenTitlesRepository.kt index 54158fa..8811336 100644 --- a/app/src/main/java/com/ivor/openstream/data/repository/HiddenTitlesRepository.kt +++ b/app/src/main/java/com/ivor/openstream/data/repository/HiddenTitlesRepository.kt @@ -2,26 +2,34 @@ package com.ivor.openstream.data.repository import com.ivor.openstream.data.local.dao.HiddenTitleDao import com.ivor.openstream.data.local.entity.HiddenTitleEntity +import com.ivor.openstream.data.settings.AppSettingsStore +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton -/** Titles the user said they're not interested in, keyed as "mediaType:tmdbId". */ +/** Titles the active profile said it's not interested in, keyed as "mediaType:tmdbId". */ +@OptIn(ExperimentalCoroutinesApi::class) @Singleton class HiddenTitlesRepository @Inject constructor( - private val dao: HiddenTitleDao + private val dao: HiddenTitleDao, + private val settings: AppSettingsStore ) { - val hiddenKeys: Flow> = dao.observeAll().map { rows -> rows.mapTo(HashSet()) { key(it.mediaType, it.tmdbId) } } + private val rows = settings.activeProfileId.flatMapLatest { dao.observeAll(it) } - val count: Flow = dao.observeAll().map { it.size } + val hiddenKeys: Flow> = rows.map { list -> list.mapTo(HashSet()) { key(it.mediaType, it.tmdbId) } } - suspend fun hide(mediaType: String, tmdbId: Int, title: String) = - dao.insert(HiddenTitleEntity(tmdbId = tmdbId, mediaType = mediaType, title = title)) + val count: Flow = rows.map { it.size } - suspend fun unhide(mediaType: String, tmdbId: Int) = dao.delete(mediaType, tmdbId) + suspend fun hide(mediaType: String, tmdbId: Int, title: String) = dao.insert( + HiddenTitleEntity(tmdbId = tmdbId, mediaType = mediaType, title = title, profileId = settings.activeProfileId.value) + ) - suspend fun unhideAll() = dao.clear() + suspend fun unhide(mediaType: String, tmdbId: Int) = dao.delete(settings.activeProfileId.value, mediaType, tmdbId) + + suspend fun unhideAll() = dao.clear(settings.activeProfileId.value) companion object { fun key(mediaType: String, tmdbId: Int) = "$mediaType:$tmdbId" diff --git a/app/src/main/java/com/ivor/openstream/data/repository/KidsContentFilter.kt b/app/src/main/java/com/ivor/openstream/data/repository/KidsContentFilter.kt new file mode 100644 index 0000000..cbe291e --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/repository/KidsContentFilter.kt @@ -0,0 +1,82 @@ +package com.ivor.openstream.data.repository + +import com.ivor.openstream.data.remote.TmdbApi +import com.ivor.openstream.data.remote.model.AnimeDto +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * What a kids profile may see: US certification G/PG for movies, TV-Y/TV-Y7/TV-G/TV-PG for shows. + * + * Discover requests carry TMDB's `certification_country`/`certification.lte` filters. Lists TMDB + * can't filter (trending, airing, search) and shows are checked title by title against their US + * rating (`release_dates` / `content_ratings`); a title without a US rating is left out. + */ +@Singleton +class KidsContentFilter @Inject constructor( + private val api: TmdbApi, + private val profiles: ProfileRepository +) { + private val verdicts = ConcurrentHashMap() + private val permits = Semaphore(6) + + val isActive: Boolean get() = profiles.isKidsActive + + /** Extra query parameters for `discover/movie` while a kids profile is active. */ + fun movieDiscoverParams(): Map = + if (isActive) mapOf("certification_country" to "US", "certification.lte" to "PG") else emptyMap() + + /** Extra query parameters for `discover/tv` while a kids profile is active. */ + fun tvDiscoverParams(): Map = + if (isActive) mapOf("certification_country" to "US", "certification.lte" to "TV-PG") else emptyMap() + + /** The titles a kids profile may see, in order; everything when no kids profile is active. */ + suspend fun filter(items: List, moviesPreFiltered: Boolean = false): List { + if (!isActive || items.isEmpty()) return items + return coroutineScope { + items.map { item -> + async { + val allowed = when { + item.adult == true -> false + item.isMovie && moviesPreFiltered -> true + else -> permits.withPermit { isAllowed(item) } + } + item.takeIf { allowed } + } + }.awaitAll().filterNotNull() + } + } + + private suspend fun isAllowed(item: AnimeDto): Boolean { + val key = "${if (item.isMovie) "movie" else "tv"}:${item.id}" + verdicts[key]?.let { return it } + val verdict = runCatching { + if (item.isMovie) { + api.getMovieReleaseDates(item.id).results + .firstOrNull { it.country == "US" } + ?.releaseDates?.map { it.certification.trim() }?.filter { it.isNotEmpty() } + ?.let { certifications -> certifications.isNotEmpty() && certifications.all { it in MOVIE_RATINGS } } + ?: false + } else { + api.getTvContentRatings(item.id).results + .firstOrNull { it.country == "US" } + ?.rating?.trim() + ?.let { it in TV_RATINGS } + ?: false + } + }.getOrNull() ?: return false // Network trouble: hide it now, ask again next time. + verdicts[key] = verdict + return verdict + } + + private companion object { + val MOVIE_RATINGS = setOf("G", "PG") + val TV_RATINGS = setOf("TV-Y", "TV-Y7", "TV-G", "TV-PG") + } +} diff --git a/app/src/main/java/com/ivor/openstream/data/repository/ProfileRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/ProfileRepository.kt new file mode 100644 index 0000000..36cfda0 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/repository/ProfileRepository.kt @@ -0,0 +1,86 @@ +package com.ivor.openstream.data.repository + +import com.ivor.openstream.data.local.dao.ProfileDao +import com.ivor.openstream.data.local.entity.ProfileEntity +import com.ivor.openstream.data.settings.AppSettingsStore +import com.ivor.openstream.domain.model.Profile +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Profiles and which one is active. The active id lives in [AppSettingsStore]; every library + * repository follows it. There is always at least one profile. + */ +@Singleton +class ProfileRepository @Inject constructor( + private val dao: ProfileDao, + private val settings: AppSettingsStore +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + val profiles: StateFlow?> = dao.observeAll() + .map { rows -> rows.map { it.toDomain() } } + .stateIn(scope, SharingStarted.Eagerly, null) + + /** The active profile; null only until the table has loaded. */ + val activeProfile: StateFlow = combine(profiles, settings.activeProfileId) { list, id -> + list?.let { all -> all.firstOrNull { it.id == id } ?: all.firstOrNull() } + }.stateIn(scope, SharingStarted.Eagerly, null) + + val activeProfileId: StateFlow get() = settings.activeProfileId + + val isKidsActive: Boolean get() = activeProfile.value?.isKids == true + + init { + // A deleted or unknown active id falls back to the first profile; an empty table gets one. + scope.launch { + if (dao.all().isEmpty()) { + dao.insert(ProfileEntity(id = ProfileEntity.DEFAULT_ID, name = DEFAULT_NAME, avatar = DEFAULT_AVATAR, isKids = false)) + } + val all = dao.all() + if (all.none { it.id == settings.activeProfileId.value }) all.firstOrNull()?.let { settings.setActiveProfile(it.id) } + } + } + + fun switchTo(id: Long) = settings.setActiveProfile(id) + + suspend fun create(name: String, avatar: String, isKids: Boolean): Long = + dao.insert(ProfileEntity(name = name.trim(), avatar = avatar, isKids = isKids)) + + suspend fun update(id: Long, name: String, avatar: String, isKids: Boolean) = + dao.update(id, name.trim(), avatar, isKids) + + /** + * Deletes a profile and its library. The last profile can't be deleted; deleting the active + * one switches to the first remaining. Returns false when nothing was deleted. + */ + suspend fun delete(id: Long): Boolean { + val all = dao.all() + if (all.size <= 1 || all.none { it.id == id }) return false + dao.deleteWithData(id) + if (settings.activeProfileId.value == id) all.first { it.id != id }.let { settings.setActiveProfile(it.id) } + return true + } + + suspend fun all(): List = dao.all().map { it.toDomain() } + + /** Finds a profile by name or makes it; used when restoring backups. */ + suspend fun findOrCreate(name: String, avatar: String, isKids: Boolean): Long = + dao.findByName(name.trim()) ?: create(name, avatar, isKids) + + private fun ProfileEntity.toDomain() = Profile(id, name, avatar, isKids) + + companion object { + const val DEFAULT_NAME = "Main" + const val DEFAULT_AVATAR = "fox" + } +} diff --git a/app/src/main/java/com/ivor/openstream/data/repository/WatchLaterRepositoryImpl.kt b/app/src/main/java/com/ivor/openstream/data/repository/WatchLaterRepositoryImpl.kt index 691112f..dfb8383 100644 --- a/app/src/main/java/com/ivor/openstream/data/repository/WatchLaterRepositoryImpl.kt +++ b/app/src/main/java/com/ivor/openstream/data/repository/WatchLaterRepositoryImpl.kt @@ -2,31 +2,37 @@ package com.ivor.openstream.data.repository import com.ivor.openstream.data.local.dao.WatchLaterDao import com.ivor.openstream.data.local.entity.WatchLaterEntity +import com.ivor.openstream.data.settings.AppSettingsStore import com.ivor.openstream.domain.repository.WatchLaterRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import javax.inject.Inject +/** Watch Later of the active profile; reads follow profile switches. */ +@OptIn(ExperimentalCoroutinesApi::class) class WatchLaterRepositoryImpl @Inject constructor( - private val dao: WatchLaterDao + private val dao: WatchLaterDao, + private val settings: AppSettingsStore ) : WatchLaterRepository { - override fun getWatchLaterList(): Flow> { - return dao.getAllWatchLaterItems() - } + private val profileId: Long get() = settings.activeProfileId.value - override fun isWatchLater(id: Int): Flow { - return dao.isWatchLater(id) - } + override fun getWatchLaterList(): Flow> = + settings.activeProfileId.flatMapLatest { dao.getAllWatchLaterItems(it) } + + override fun isWatchLater(id: Int): Flow = + settings.activeProfileId.flatMapLatest { dao.isWatchLater(it, id) } override suspend fun addToWatchLater(item: WatchLaterEntity) { - dao.insertWatchLaterItem(item) + dao.insertWatchLaterItem(item.copy(profileId = profileId)) } override suspend fun removeFromWatchLater(item: WatchLaterEntity) { - dao.deleteWatchLaterItem(item) + dao.deleteWatchLaterItemById(item.profileId, item.id) } override suspend fun removeFromWatchLaterById(id: Int) { - dao.deleteWatchLaterItemById(id) + dao.deleteWatchLaterItemById(profileId, id) } } diff --git a/app/src/main/java/com/ivor/openstream/data/repository/WatchProgressRepositoryImpl.kt b/app/src/main/java/com/ivor/openstream/data/repository/WatchProgressRepositoryImpl.kt index 3663b96..8d38e8b 100644 --- a/app/src/main/java/com/ivor/openstream/data/repository/WatchProgressRepositoryImpl.kt +++ b/app/src/main/java/com/ivor/openstream/data/repository/WatchProgressRepositoryImpl.kt @@ -3,7 +3,10 @@ package com.ivor.openstream.data.repository import com.ivor.openstream.data.local.dao.WatchProgressDao import com.ivor.openstream.data.local.entity.WatchProgressEntity import com.ivor.openstream.domain.model.WatchProgress +import com.ivor.openstream.data.settings.AppSettingsStore import com.ivor.openstream.domain.repository.WatchProgressRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -13,42 +16,52 @@ import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton +/** Progress of the active profile; reads follow profile switches, writes go to the active one. */ +@OptIn(ExperimentalCoroutinesApi::class) @Singleton class WatchProgressRepositoryImpl @Inject constructor( - private val dao: WatchProgressDao + private val dao: WatchProgressDao, + private val settings: AppSettingsStore ) : WatchProgressRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val profileId: Long get() = settings.activeProfileId.value + override fun continueWatching(limit: Int): Flow> = - dao.observeContinueWatching(limit).map { rows -> rows.map { it.toDomain() } } + settings.activeProfileId.flatMapLatest { dao.observeContinueWatching(it, limit) } + .map { rows -> rows.map { it.toDomain() } } override fun allProgress(): Flow> = - dao.observeAll().map { rows -> rows.map { it.toDomain() } } + settings.activeProfileId.flatMapLatest { dao.observeAll(it) } + .map { rows -> rows.map { it.toDomain() } } override fun progressForTitle(mediaType: String, tmdbId: Int): Flow> = - dao.observeForTitle(mediaType, tmdbId).map { rows -> rows.map { it.toDomain() } } + settings.activeProfileId.flatMapLatest { dao.observeForTitle(it, mediaType, tmdbId) } + .map { rows -> rows.map { it.toDomain() } } override suspend fun get(mediaType: String, tmdbId: Int, season: Int, episode: Int): WatchProgress? = - dao.get(WatchProgressEntity.idFor(mediaType, tmdbId, season, episode))?.toDomain() + dao.get(profileId, WatchProgressEntity.idFor(mediaType, tmdbId, season, episode))?.toDomain() override fun record(progress: WatchProgress) { - scope.launch { dao.upsert(progress.toEntity()) } + // The profile is read now, not when the write runs, so a switch can't redirect it. + val entity = progress.toEntity(profileId) + scope.launch { dao.upsert(entity) } } override suspend fun dismiss(mediaType: String, tmdbId: Int) { - dao.deleteUnfinished(mediaType, tmdbId) + dao.deleteUnfinished(profileId, mediaType, tmdbId) } override suspend fun clearTitle(mediaType: String, tmdbId: Int) { - dao.deleteForTitle(mediaType, tmdbId) + dao.deleteForTitle(profileId, mediaType, tmdbId) } override suspend fun clearEpisode(mediaType: String, tmdbId: Int, season: Int, episode: Int) { - dao.delete(WatchProgressEntity.idFor(mediaType, tmdbId, season, episode)) + dao.delete(profileId, WatchProgressEntity.idFor(mediaType, tmdbId, season, episode)) } override suspend fun clearAll() { - dao.clear() + dao.clear(profileId) } private fun WatchProgressEntity.toDomain() = WatchProgress( @@ -67,7 +80,7 @@ class WatchProgressRepositoryImpl @Inject constructor( updatedAt = updatedAt ) - private fun WatchProgress.toEntity() = WatchProgressEntity( + private fun WatchProgress.toEntity(profileId: Long) = WatchProgressEntity( id = WatchProgressEntity.idFor(mediaType, tmdbId, season, episode), tmdbId = tmdbId, mediaType = mediaType, @@ -81,6 +94,7 @@ class WatchProgressRepositoryImpl @Inject constructor( positionMs = positionMs, durationMs = durationMs, completed = completed, - updatedAt = updatedAt + updatedAt = updatedAt, + profileId = profileId ) } diff --git a/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt b/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt index 853194f..e286107 100644 --- a/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt +++ b/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt @@ -57,6 +57,19 @@ class AppSettingsStore @Inject constructor( val current: AppSettings get() = _settings.value + private val _activeProfileId = MutableStateFlow(prefs.getLong(KEY_ACTIVE_PROFILE, DEFAULT_PROFILE_ID)) + + /** + * The profile whose library, progress and lists the app shows. Not part of [AppSettings] + * because backups and restores shouldn't switch who's watching. + */ + val activeProfileId: StateFlow = _activeProfileId.asStateFlow() + + fun setActiveProfile(id: Long) { + _activeProfileId.value = id + prefs.edit().putLong(KEY_ACTIVE_PROFILE, id).apply() + } + fun update(transform: (AppSettings) -> AppSettings) { val updated = transform(_settings.value) _settings.value = updated @@ -101,5 +114,8 @@ class AppSettingsStore @Inject constructor( const val KEY_DEFAULT_SPEED = "app_default_speed" const val KEY_AUTO_PLAY_NEXT = "app_auto_play_next" const val KEY_DOWNLOAD_HEIGHT = "app_download_max_height" + const val KEY_ACTIVE_PROFILE = "app_active_profile_id" + /** Matches ProfileEntity.DEFAULT_ID, the profile the Room migration seeds. */ + const val DEFAULT_PROFILE_ID = 1L } } diff --git a/app/src/main/java/com/ivor/openstream/di/DatabaseModule.kt b/app/src/main/java/com/ivor/openstream/di/DatabaseModule.kt index ffa42ef..8be9953 100644 --- a/app/src/main/java/com/ivor/openstream/di/DatabaseModule.kt +++ b/app/src/main/java/com/ivor/openstream/di/DatabaseModule.kt @@ -2,6 +2,10 @@ package com.ivor.openstream.di import android.content.Context import androidx.room.Room +import androidx.room.RoomDatabase +import com.ivor.openstream.data.local.dao.ProfileDao +import com.ivor.openstream.data.local.entity.ProfileEntity +import com.ivor.openstream.data.repository.ProfileRepository import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.ivor.openstream.data.local.AppDatabase @@ -131,6 +135,92 @@ object DatabaseModule { } } + /** + * Profiles: a `profiles` table seeded with the default profile, and a `profileId` on every + * per-person table. Primary keys gain the profile so two profiles can hold the same title; + * SQLite can't change a primary key in place, so those tables are rebuilt. Existing rows all + * go to the default profile. Downloads stay device-wide. + */ + private val migration7To8 = object : Migration(7, 8) { + override fun migrate(database: SupportSQLiteDatabase) { + val defaultId = ProfileEntity.DEFAULT_ID + database.execSQL( + "CREATE TABLE IF NOT EXISTS `profiles` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`name` TEXT NOT NULL, `avatar` TEXT NOT NULL, `isKids` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL)" + ) + seedDefaultProfile(database) + + database.execSQL( + "CREATE TABLE IF NOT EXISTS `watch_later_new` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, " + + "`posterPath` TEXT, `mediaType` TEXT NOT NULL, `voteAverage` REAL NOT NULL, `dateAdded` INTEGER NOT NULL, " + + "`profileId` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `id`))" + ) + database.execSQL( + "INSERT INTO `watch_later_new` (`id`, `title`, `posterPath`, `mediaType`, `voteAverage`, `dateAdded`, `profileId`) " + + "SELECT `id`, `title`, `posterPath`, `mediaType`, `voteAverage`, `dateAdded`, $defaultId FROM `watch_later`" + ) + database.execSQL("DROP TABLE `watch_later`") + database.execSQL("ALTER TABLE `watch_later_new` RENAME TO `watch_later`") + + database.execSQL( + "CREATE TABLE IF NOT EXISTS `watch_progress_new` (`id` TEXT NOT NULL, `tmdbId` INTEGER NOT NULL, " + + "`mediaType` TEXT NOT NULL, `season` INTEGER NOT NULL, `episode` INTEGER NOT NULL, `title` TEXT NOT NULL, " + + "`episodeTitle` TEXT, `posterPath` TEXT, `backdropPath` TEXT, `stillPath` TEXT, `positionMs` INTEGER NOT NULL, " + + "`durationMs` INTEGER NOT NULL, `completed` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, " + + "`profileId` INTEGER NOT NULL, PRIMARY KEY(`profileId`, `id`))" + ) + database.execSQL( + "INSERT INTO `watch_progress_new` (`id`, `tmdbId`, `mediaType`, `season`, `episode`, `title`, `episodeTitle`, " + + "`posterPath`, `backdropPath`, `stillPath`, `positionMs`, `durationMs`, `completed`, `updatedAt`, `profileId`) " + + "SELECT `id`, `tmdbId`, `mediaType`, `season`, `episode`, `title`, `episodeTitle`, `posterPath`, " + + "`backdropPath`, `stillPath`, `positionMs`, `durationMs`, `completed`, `updatedAt`, $defaultId FROM `watch_progress`" + ) + database.execSQL("DROP TABLE `watch_progress`") + database.execSQL("ALTER TABLE `watch_progress_new` RENAME TO `watch_progress`") + database.execSQL( + "CREATE INDEX IF NOT EXISTS `index_watch_progress_profileId_mediaType_tmdbId` " + + "ON `watch_progress` (`profileId`, `mediaType`, `tmdbId`)" + ) + + database.execSQL( + "CREATE TABLE IF NOT EXISTS `hidden_titles_new` (`tmdbId` INTEGER NOT NULL, `mediaType` TEXT NOT NULL, " + + "`title` TEXT NOT NULL, `hiddenAt` INTEGER NOT NULL, `profileId` INTEGER NOT NULL, " + + "PRIMARY KEY(`profileId`, `mediaType`, `tmdbId`))" + ) + database.execSQL( + "INSERT INTO `hidden_titles_new` (`tmdbId`, `mediaType`, `title`, `hiddenAt`, `profileId`) " + + "SELECT `tmdbId`, `mediaType`, `title`, `hiddenAt`, $defaultId FROM `hidden_titles`" + ) + database.execSQL("DROP TABLE `hidden_titles`") + database.execSQL("ALTER TABLE `hidden_titles_new` RENAME TO `hidden_titles`") + + database.execSQL( + "CREATE TABLE IF NOT EXISTS `custom_lists_new` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`name` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `profileId` INTEGER NOT NULL)" + ) + database.execSQL( + "INSERT INTO `custom_lists_new` (`id`, `name`, `createdAt`, `updatedAt`, `profileId`) " + + "SELECT `id`, `name`, `createdAt`, `updatedAt`, $defaultId FROM `custom_lists`" + ) + database.execSQL("DROP TABLE `custom_lists`") + database.execSQL("ALTER TABLE `custom_lists_new` RENAME TO `custom_lists`") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_custom_lists_profileId` ON `custom_lists` (`profileId`)") + } + } + + /** The profile every existing row is given; also created on a fresh install. */ + private fun seedDefaultProfile(database: SupportSQLiteDatabase) { + database.execSQL( + "INSERT OR IGNORE INTO `profiles` (`id`, `name`, `avatar`, `isKids`, `createdAt`) VALUES (?, ?, ?, 0, ?)", + arrayOf( + ProfileEntity.DEFAULT_ID, + ProfileRepository.DEFAULT_NAME, + ProfileRepository.DEFAULT_AVATAR, + System.currentTimeMillis() + ) + ) + } + @Provides @Singleton fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { @@ -139,7 +229,10 @@ object DatabaseModule { AppDatabase::class.java, "open_stream_db" ) - .addMigrations(migration2To3, migration3To4, migration4To5, migration5To6, migration6To7) + .addMigrations(migration2To3, migration3To4, migration4To5, migration5To6, migration6To7, migration7To8) + .addCallback(object : RoomDatabase.Callback() { + override fun onCreate(db: SupportSQLiteDatabase) = seedDefaultProfile(db) + }) .fallbackToDestructiveMigration() .build() } @@ -164,6 +257,11 @@ object DatabaseModule { return database.hiddenTitleDao() } + @Provides + fun provideProfileDao(database: AppDatabase): ProfileDao { + return database.profileDao() + } + @Provides fun provideCustomListDao(database: AppDatabase): CustomListDao { return database.customListDao() diff --git a/app/src/main/java/com/ivor/openstream/di/NetworkModule.kt b/app/src/main/java/com/ivor/openstream/di/NetworkModule.kt index 82f9d4b..9376e16 100644 --- a/app/src/main/java/com/ivor/openstream/di/NetworkModule.kt +++ b/app/src/main/java/com/ivor/openstream/di/NetworkModule.kt @@ -38,6 +38,10 @@ object NetworkModule { val originalHttpUrl = original.url val url = originalHttpUrl.newBuilder() .addQueryParameter("api_key", BuildConfig.TMDB_API_KEY) + .apply { + // No adult titles anywhere in the app, for every profile. + if (originalHttpUrl.host == "api.themoviedb.org" && originalHttpUrl.queryParameter("include_adult") == null) addQueryParameter("include_adult", "false") + } .build() val request = original.newBuilder() diff --git a/app/src/main/java/com/ivor/openstream/domain/model/Profile.kt b/app/src/main/java/com/ivor/openstream/domain/model/Profile.kt new file mode 100644 index 0000000..f6704c4 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/domain/model/Profile.kt @@ -0,0 +1,11 @@ +package com.ivor.openstream.domain.model + +/** Someone who watches on this device, with their own library and progress. */ +data class Profile( + val id: Long, + val name: String, + /** Built-in avatar key; the UI maps it to an icon and color role. */ + val avatar: String, + /** Home and Search only show titles rated for children; Settings sits behind a hold gesture. */ + val isKids: Boolean +) diff --git a/app/src/main/java/com/ivor/openstream/presentation/home/HomeScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/home/HomeScreen.kt index 36862cd..626ee9a 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/home/HomeScreen.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/home/HomeScreen.kt @@ -26,6 +26,8 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings +import com.ivor.openstream.domain.model.Profile +import com.ivor.openstream.presentation.profiles.ProfileSwitchButton import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.material.icons.filled.VisibilityOff @@ -88,6 +90,8 @@ fun HomeScreen( onOpenDetails: (mediaType: String, id: Int) -> Unit, onSettingsClick: () -> Unit, onUpdateClick: () -> Unit = {}, + profile: Profile? = null, + onSwitchProfile: () -> Unit = {}, viewModel: HomeViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() @@ -132,7 +136,12 @@ fun HomeScreen( items = state.hero, onOpen = open, onSettingsClick = onSettingsClick, - onUpdateClick = onUpdateClick + onUpdateClick = onUpdateClick, + profile = profile, + onSwitchProfile = onSwitchProfile, + onKidsTap = { + scope.launch { snackbarHostState.showSnackbar("Hold the avatar to leave the kids profile") } + } ) } @@ -180,7 +189,10 @@ private fun HeroSection( items: List, onOpen: (AnimeDto) -> Unit, onSettingsClick: () -> Unit, - onUpdateClick: () -> Unit + onUpdateClick: () -> Unit, + profile: Profile?, + onSwitchProfile: () -> Unit, + onKidsTap: () -> Unit ) { Column(modifier = Modifier.statusBarsPadding()) { Row( @@ -196,12 +208,16 @@ private fun HeroSection( color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.weight(1f) ) - IconButton(onClick = onUpdateClick) { - Icon(Icons.Default.SystemUpdate, contentDescription = "Check for updates") - } - IconButton(onClick = onSettingsClick) { - Icon(Icons.Default.Settings, contentDescription = "Settings") + // A kids profile keeps Settings and updates out of reach; leaving takes a hold on the avatar. + if (profile?.isKids != true) { + IconButton(onClick = onUpdateClick) { + Icon(Icons.Default.SystemUpdate, contentDescription = "Check for updates") + } + IconButton(onClick = onSettingsClick) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } } + ProfileSwitchButton(profile = profile, onSwitch = onSwitchProfile, onKidsTap = onKidsTap) } if (items.isNotEmpty()) { diff --git a/app/src/main/java/com/ivor/openstream/presentation/home/HomeViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/home/HomeViewModel.kt index 170118c..ecf5577 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/home/HomeViewModel.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/home/HomeViewModel.kt @@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ivor.openstream.data.remote.model.AnimeDto import com.ivor.openstream.data.repository.HiddenTitlesRepository +import com.ivor.openstream.data.repository.ProfileRepository +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import com.ivor.openstream.domain.model.AnimeCatalog import com.ivor.openstream.domain.model.WatchProgress import com.ivor.openstream.domain.repository.AnimeRepository @@ -52,7 +55,8 @@ sealed interface HomeUiState { class HomeViewModel @Inject constructor( private val repository: AnimeRepository, private val watchProgressRepository: WatchProgressRepository, - private val hiddenTitlesRepository: HiddenTitlesRepository + private val hiddenTitlesRepository: HiddenTitlesRepository, + profileRepository: ProfileRepository ) : ViewModel() { /** The feed as loaded; [uiState] is this minus the titles the user hid. */ @@ -65,7 +69,13 @@ class HomeViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) init { - loadData() + // First load, and again whenever the feed has to change: switching to or from a kids profile. + viewModelScope.launch { + profileRepository.activeProfile + .map { it?.isKids } + .distinctUntilChanged() + .collect { kids -> if (kids != null) loadData() } + } } fun hideTitle(anime: AnimeDto) { diff --git a/app/src/main/java/com/ivor/openstream/presentation/navigation/AppNavigation.kt b/app/src/main/java/com/ivor/openstream/presentation/navigation/AppNavigation.kt index 67eed3b..df08180 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/navigation/AppNavigation.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/navigation/AppNavigation.kt @@ -37,6 +37,11 @@ import com.ivor.openstream.presentation.settings.SettingsScreen import com.ivor.openstream.presentation.marketplace.MarketplaceScreen import com.ivor.openstream.presentation.player.session.MiniPlayer import com.ivor.openstream.presentation.lists.CustomListScreen +import com.ivor.openstream.presentation.profiles.ManageProfilesScreen +import com.ivor.openstream.presentation.profiles.ProfilesViewModel +import com.ivor.openstream.presentation.profiles.WhoIsWatchingScreen +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.saveable.rememberSaveable import com.ivor.openstream.presentation.welcome.WelcomeSheet import com.ivor.openstream.presentation.shortcuts.AppShortcut import com.ivor.openstream.presentation.shortcuts.ShortcutRequest @@ -107,6 +112,8 @@ sealed class Screen( fun createRoute(personId: Int) = "person/$personId" } + data object Profiles : Screen("profiles") + data object CustomList : Screen("list/{listId}") { fun createRoute(listId: Long) = "list/$listId" } @@ -149,6 +156,11 @@ fun AppNavigation( val miniPlayerViewModel: MiniPlayerViewModel = hiltViewModel() val nowPlaying by miniPlayerViewModel.session.nowPlaying.collectAsState() val appViewModel: AppViewModel = hiltViewModel() + val profilesViewModel: ProfilesViewModel = hiltViewModel() + val profiles by profilesViewModel.profiles.collectAsState() + val activeProfile by profilesViewModel.activeProfile.collectAsState() + val showLaunchPicker by profilesViewModel.showLaunchPicker.collectAsState() + var showProfilePicker by rememberSaveable { mutableStateOf(false) } val isOnline by appViewModel.isOnline.collectAsState() fun openTab(screen: Screen) { @@ -256,7 +268,9 @@ fun AppNavigation( navController.navigate(Screen.Details.createRoute(mediaType, id)) }, onSettingsClick = { navController.navigate(Screen.Settings.route) }, - onUpdateClick = { navController.navigate(Screen.Update.route) } + onUpdateClick = { navController.navigate(Screen.Update.route) }, + profile = activeProfile, + onSwitchProfile = { showProfilePicker = true } ) } @@ -336,7 +350,15 @@ fun AppNavigation( composable(Screen.Settings.route) { SettingsScreen( onBackClick = { navController.popBackStack() }, - onOpenMarketplace = { navController.navigate(Screen.Marketplace.route) } + onOpenMarketplace = { navController.navigate(Screen.Marketplace.route) }, + onOpenProfiles = { navController.navigate(Screen.Profiles.route) } + ) + } + + composable(Screen.Profiles.route) { + ManageProfilesScreen( + viewModel = profilesViewModel, + onBackClick = { navController.popBackStack() } ) } @@ -553,6 +575,29 @@ fun AppNavigation( } } } + + // "Who's watching?": on launch with two or more profiles, or from the Home avatar. + androidx.compose.animation.AnimatedVisibility( + visible = showLaunchPicker || showProfilePicker, + enter = fadeIn(), + exit = fadeOut() + ) { + BackHandler(enabled = showProfilePicker && !showLaunchPicker) { showProfilePicker = false } + WhoIsWatchingScreen( + profiles = profiles, + activeId = activeProfile?.id, + onSelect = { profile -> + profilesViewModel.select(profile.id) + showProfilePicker = false + openTab(Screen.Home) + }, + onManage = { + profilesViewModel.dismissLaunchPicker() + showProfilePicker = false + navController.navigate(Screen.Profiles.route) { launchSingleTop = true } + } + ) + } } @Composable diff --git a/app/src/main/java/com/ivor/openstream/presentation/profiles/ProfileAvatar.kt b/app/src/main/java/com/ivor/openstream/presentation/profiles/ProfileAvatar.kt new file mode 100644 index 0000000..1fb35a1 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/profiles/ProfileAvatar.kt @@ -0,0 +1,82 @@ +package com.ivor.openstream.presentation.profiles + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Eco +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Icecream +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Pets +import androidx.compose.material.icons.filled.RocketLaunch +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material.icons.filled.Star +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +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.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.RoundedPolygon + +/** Color role an avatar is drawn in, so avatars follow the theme (and dynamic color). */ +enum class AvatarTone { PRIMARY, SECONDARY, TERTIARY } + +/** + * Built-in profile avatars. [key] is what the `profiles.avatar` column stores; the icon, shape and + * tone are how it looks. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +enum class ProfileAvatar(val key: String, val icon: ImageVector, val shape: () -> RoundedPolygon, val tone: AvatarTone) { + FOX("fox", Icons.Default.Pets, { MaterialShapes.Cookie9Sided }, AvatarTone.PRIMARY), + ROCKET("rocket", Icons.Default.RocketLaunch, { MaterialShapes.Sunny }, AvatarTone.TERTIARY), + STAR("star", Icons.Default.Star, { MaterialShapes.Clover4Leaf }, AvatarTone.SECONDARY), + FACE("face", Icons.Default.Face, { MaterialShapes.Circle }, AvatarTone.PRIMARY), + GAME("game", Icons.Default.SportsEsports, { MaterialShapes.Pill }, AvatarTone.TERTIARY), + MUSIC("music", Icons.Default.MusicNote, { MaterialShapes.Flower }, AvatarTone.SECONDARY), + LEAF("leaf", Icons.Default.Eco, { MaterialShapes.Gem }, AvatarTone.PRIMARY), + ROBOT("robot", Icons.Default.SmartToy, { MaterialShapes.Cookie6Sided }, AvatarTone.SECONDARY), + SPARKLE("sparkle", Icons.Default.AutoAwesome, { MaterialShapes.SoftBurst }, AvatarTone.TERTIARY), + ICECREAM("icecream", Icons.Default.Icecream, { MaterialShapes.Puffy }, AvatarTone.PRIMARY); + + companion object { + fun from(key: String?): ProfileAvatar = entries.firstOrNull { it.key == key } ?: FOX + } +} + +@Composable +private fun AvatarTone.colors(): Pair = when (this) { + AvatarTone.PRIMARY -> MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer + AvatarTone.SECONDARY -> MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer + AvatarTone.TERTIARY -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer +} + +/** A profile's avatar: its icon inside its expressive shape. Decorative; callers label it. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ProfileAvatarBadge(avatarKey: String, size: Dp, modifier: Modifier = Modifier) { + val avatar = ProfileAvatar.from(avatarKey) + val (container, content) = avatar.tone.colors() + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(size) + .clip(avatar.shape().toShape()) + .background(container) + ) { + Icon(avatar.icon, contentDescription = null, tint = content, modifier = Modifier.size(size * 0.46f)) + } +} + +/** Avatar size for small places like the Home top bar. */ +val SmallAvatar = 36.dp diff --git a/app/src/main/java/com/ivor/openstream/presentation/profiles/ProfileScreens.kt b/app/src/main/java/com/ivor/openstream/presentation/profiles/ProfileScreens.kt new file mode 100644 index 0000000..042f845 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/profiles/ProfileScreens.kt @@ -0,0 +1,410 @@ +package com.ivor.openstream.presentation.profiles + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ChildCare +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.onLongClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ivor.openstream.domain.model.Profile +import com.ivor.openstream.presentation.components.LibraryHeader +import com.ivor.openstream.ui.theme.ExpressiveShapes +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** "Who's watching?": big avatar tiles, one per profile, plus a way to manage them. */ +@Composable +fun WhoIsWatchingScreen( + profiles: List, + activeId: Long?, + onSelect: (Profile) -> Unit, + onManage: () -> Unit, + modifier: Modifier = Modifier +) { + Surface(color = MaterialTheme.colorScheme.background, modifier = modifier.fillMaxSize()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + .padding(horizontal = 24.dp) + ) { + Spacer(Modifier.height(48.dp)) + Text( + text = "Who's watching?", + style = MaterialTheme.typography.displaySmall, + fontWeight = FontWeight.Black, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { heading() } + ) + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 140.dp), + contentPadding = PaddingValues(vertical = 32.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.weight(1f) + ) { + items(profiles, key = { it.id }) { profile -> + ProfileTile(profile = profile, selected = profile.id == activeId, onClick = { onSelect(profile) }) + } + } + OutlinedButton(onClick = onManage, shape = ExpressiveShapes.medium, modifier = Modifier.padding(bottom = 24.dp)) { + Icon(Icons.Default.Edit, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("Manage profiles") + } + } + } +} + +@Composable +private fun ProfileTile(profile: Profile, selected: Boolean, onClick: () -> Unit) { + val scale by animateFloatAsState(if (selected) 1f else 0.92f, tween(300), label = "profileTileScale") + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .clip(ExpressiveShapes.large) + .clickable(role = Role.Button, onClickLabel = "Watch as ${profile.name}", onClick = onClick) + .padding(8.dp) + ) { + ProfileAvatarBadge(profile.avatar, size = 120.dp * scale) + Text( + text = profile.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 12.dp) + ) + if (profile.isKids) KidsLabel() + } +} + +@Composable +private fun KidsLabel() { + Surface(shape = ExpressiveShapes.small, color = MaterialTheme.colorScheme.tertiaryContainer) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)) { + Icon(Icons.Default.ChildCare, contentDescription = null, modifier = Modifier.size(14.dp)) + Text("Kids", style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp)) + } + } +} + +/** + * The active profile's avatar in the Home top bar. Tap switches profile; on a kids profile it + * takes a [KIDS_HOLD_MS] hold instead (a ring fills while holding), so children stay in. + */ +@Composable +fun ProfileSwitchButton( + profile: Profile?, + onSwitch: () -> Unit, + onKidsTap: () -> Unit, + modifier: Modifier = Modifier +) { + val haptics = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + var holding by remember { mutableStateOf(false) } + val progress by animateFloatAsState( + targetValue = if (holding) 1f else 0f, + animationSpec = tween(if (holding) KIDS_HOLD_MS.toInt() else 150), + label = "kidsHold" + ) + val isKids = profile?.isKids == true + val label = when { + profile == null -> "Profiles" + isKids -> "Kids profile ${profile.name}. Hold to exit." + else -> "Profile ${profile.name}. Switch profile." + } + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(48.dp) + .clip(ExpressiveShapes.extraLarge) + .semantics { + contentDescription = label + if (isKids) onLongClick("Exit kids profile") { onSwitch(); true } + } + .then( + if (isKids) { + Modifier.pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown() + holding = true + var job: Job? = null + job = scope.launch { + delay(KIDS_HOLD_MS) + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + holding = false + onSwitch() + } + val up = waitForUpOrCancellation() + if (job.isActive) { + job.cancel() + holding = false + if (up != null) onKidsTap() + } + } + } + } else { + Modifier.clickable(role = Role.Button, onClick = onSwitch) + } + ) + ) { + if (progress > 0f) { + CircularProgressIndicator(progress = { progress }, modifier = Modifier.size(44.dp), strokeWidth = 3.dp) + } + ProfileAvatarBadge(profile?.avatar ?: ProfileAvatar.FOX.key, size = SmallAvatar) + } +} + +/** Settings → Profiles: add, rename, change avatar, mark as kids, delete (confirmed). */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ManageProfilesScreen( + viewModel: ProfilesViewModel, + onBackClick: () -> Unit +) { + val profiles by viewModel.profiles.collectAsState() + val active by viewModel.activeProfile.collectAsState() + var editing by remember { mutableStateOf(null) } + var adding by remember { mutableStateOf(false) } + var deleting by remember { mutableStateOf(null) } + val snackbar = remember { SnackbarHostState() } + LaunchedEffect(Unit) { viewModel.messages.collect { snackbar.showSnackbar(it) } } + + Box( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + LazyColumn(contentPadding = PaddingValues(bottom = 120.dp)) { + item(key = "header") { + LibraryHeader( + title = "Profiles", + subtitle = "Each profile has its own Watch Later, lists, history and Continue Watching. Downloads are shared.", + onBackClick = onBackClick + ) + } + items(profiles, key = { it.id }) { profile -> + ListItem( + headlineContent = { Text(profile.name, fontWeight = FontWeight.SemiBold) }, + supportingContent = { + Text( + listOfNotNull( + "Watching now".takeIf { profile.id == active?.id }, + "Kids: rated G, PG, TV-Y to TV-PG only".takeIf { profile.isKids } + ).joinToString(" · ").ifEmpty { "Tap to edit" } + ) + }, + leadingContent = { ProfileAvatarBadge(profile.avatar, size = 48.dp) }, + trailingContent = { + if (profiles.size > 1) { + IconButton(onClick = { deleting = profile }) { + Icon(Icons.Default.Delete, contentDescription = "Delete ${profile.name}") + } + } + }, + colors = ListItemDefaults.colors(containerColor = androidx.compose.ui.graphics.Color.Transparent), + modifier = Modifier.clickable(onClickLabel = "Edit ${profile.name}") { editing = profile } + ) + } + item(key = "add") { + Button( + onClick = { adding = true }, + shape = ExpressiveShapes.medium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Icon(Icons.Default.Add, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Add profile") + } + } + } + SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding().padding(16.dp)) + } + + if (adding) { + ProfileEditorDialog( + title = "New profile", + initial = null, + onSave = { name, avatar, kids -> + adding = false + viewModel.create(name, avatar, kids) + }, + onDismiss = { adding = false } + ) + } + editing?.let { profile -> + ProfileEditorDialog( + title = "Edit profile", + initial = profile, + onSave = { name, avatar, kids -> + editing = null + viewModel.update(profile.id, name, avatar, kids) + }, + onDismiss = { editing = null } + ) + } + deleting?.let { profile -> + AlertDialog( + onDismissRequest = { deleting = null }, + title = { Text("Delete ${profile.name}?") }, + text = { Text("Its Watch Later, lists, history and Continue Watching are deleted too. Downloads stay.") }, + confirmButton = { + TextButton(onClick = { + deleting = null + viewModel.delete(profile) + }) { Text("Delete") } + }, + dismissButton = { TextButton(onClick = { deleting = null }) { Text("Cancel") } } + ) + } +} + +@Composable +private fun ProfileEditorDialog( + title: String, + initial: Profile?, + onSave: (name: String, avatar: String, isKids: Boolean) -> Unit, + onDismiss: () -> Unit +) { + var name by remember { mutableStateOf(initial?.name.orEmpty()) } + var avatar by remember { mutableStateOf(initial?.avatar ?: ProfileAvatar.entries.random().key) } + var isKids by remember { mutableStateOf(initial?.isKids ?: false) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column { + OutlinedTextField( + value = name, + onValueChange = { name = it.take(24) }, + label = { Text("Name") }, + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Words), + modifier = Modifier.fillMaxWidth() + ) + Text( + "Avatar", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 16.dp, bottom = 8.dp) + ) + ProfileAvatar.entries.chunked(5).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + row.forEach { option -> + val selected = option.key == avatar + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(48.dp) + .clip(ExpressiveShapes.medium) + .background(if (selected) MaterialTheme.colorScheme.surfaceContainerHighest else androidx.compose.ui.graphics.Color.Transparent) + .selectable(selected = selected, role = Role.RadioButton, onClick = { avatar = option.key }) + .semantics { contentDescription = "Avatar ${option.key}" } + ) { + ProfileAvatarBadge(option.key, size = if (selected) 42.dp else 36.dp) + } + } + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(top = 12.dp) + .clip(ExpressiveShapes.small) + .selectable(selected = isKids, role = Role.Switch, onClick = { isKids = !isKids }) + .padding(vertical = 8.dp) + ) { + Column(Modifier.weight(1f)) { + Text("Kids profile", style = MaterialTheme.typography.bodyLarge) + Text( + "Only titles rated for children. Hold the avatar to leave.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch(checked = isKids, onCheckedChange = null) + } + } + }, + confirmButton = { + TextButton(onClick = { onSave(name.trim(), avatar, isKids) }, enabled = name.isNotBlank()) { Text("Save") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} + +/** How long a kids profile's avatar must be held to leave it. */ +const val KIDS_HOLD_MS = 1_500L diff --git a/app/src/main/java/com/ivor/openstream/presentation/profiles/ProfilesViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/profiles/ProfilesViewModel.kt new file mode 100644 index 0000000..09956fa --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/profiles/ProfilesViewModel.kt @@ -0,0 +1,74 @@ +package com.ivor.openstream.presentation.profiles + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ivor.openstream.data.repository.ProfileRepository +import com.ivor.openstream.domain.model.Profile +import com.ivor.openstream.presentation.player.session.PlaybackSession +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Profiles for the picker, the Home avatar and Settings → Profiles. */ +@HiltViewModel +class ProfilesViewModel @Inject constructor( + private val repository: ProfileRepository, + private val playbackSession: PlaybackSession +) : ViewModel() { + + val profiles: StateFlow> = repository.profiles + .map { it.orEmpty() } + .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) + + val activeProfile: StateFlow = repository.activeProfile + + /** Set once someone is picked on launch; the picker isn't asked for again this run. */ + private val launchChoiceMade = MutableStateFlow(false) + + /** "Who's watching?" on launch: only when there is more than one profile to choose from. */ + val showLaunchPicker: StateFlow = combine(repository.profiles, launchChoiceMade) { list, chosen -> + !chosen && (list?.size ?: 0) >= 2 + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + private val _messages = MutableSharedFlow(extraBufferCapacity = 2) + val messages: SharedFlow = _messages.asSharedFlow() + + /** Makes [id] the active profile. Switching ends what's playing, since it belongs to the last one. */ + fun select(id: Long) { + launchChoiceMade.value = true + if (repository.activeProfileId.value == id) return + playbackSession.stop() + repository.switchTo(id) + } + + fun dismissLaunchPicker() { + launchChoiceMade.value = true + } + + fun create(name: String, avatar: String, isKids: Boolean) { + if (name.isBlank()) return + viewModelScope.launch { repository.create(name, avatar, isKids) } + } + + fun update(id: Long, name: String, avatar: String, isKids: Boolean) { + if (name.isBlank()) return + viewModelScope.launch { repository.update(id, name, avatar, isKids) } + } + + fun delete(profile: Profile) { + viewModelScope.launch { + if (profile.id == repository.activeProfileId.value) playbackSession.stop() + if (!repository.delete(profile.id)) _messages.tryEmit("Keep at least one profile") + else _messages.tryEmit("Deleted ${profile.name}") + } + } +} diff --git a/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsScreen.kt index 65d2249..096a5b4 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Forward10 import androidx.compose.material.icons.filled.HighQuality import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Restore @@ -90,6 +91,7 @@ import com.ivor.openstream.ui.theme.ExpressiveShapes fun SettingsScreen( onBackClick: () -> Unit, onOpenMarketplace: () -> Unit, + onOpenProfiles: () -> Unit = {}, viewModel: SettingsViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsState() @@ -299,6 +301,18 @@ fun SettingsScreen( } } + item(key = "profiles") { + SettingsGroup(title = "Profiles") { + NavigationRow( + index = 0, count = 1, + icon = Icons.Default.People, + title = "Profiles", + supporting = "Add, rename or delete profiles; kids profiles", + onClick = onOpenProfiles + ) + } + } + item(key = "library") { val count = if (hiddenTitleCount > 0) 4 else 3 SettingsGroup(title = "Library", busy = isWorking) { @@ -306,7 +320,7 @@ fun SettingsScreen( index = 0, count = count, icon = Icons.Default.Backup, title = "Back up library", - supporting = "Watch Later, history, progress, hidden titles and settings", + supporting = "Every profile's Watch Later, lists, history and hidden titles, plus settings", enabled = !isWorking, onClick = { exportBackup.launch("openstream-backup-${fileDate()}.json") } ) @@ -333,7 +347,7 @@ fun SettingsScreen( index = count - 1, count = count, icon = Icons.Default.History, title = "Clear watch history", - supporting = "History, Continue Watching and resume positions", + supporting = "This profile's history, Continue Watching and resume positions", onClick = { confirmClearHistory = true } ) } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9ffbc77..1bfd72d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -80,7 +80,8 @@ brightness back to the system. - [x] **Custom lists** beyond Watch Later: "Add to list" on Details, lists on the Saved tab, rename, delete, remove with undo; included in backups. - [ ] **New episode notifications** for followed shows (needs WorkManager, not in the app yet). -- [ ] **Profiles** with separate history and progress. +- [x] **Profiles** with separate Watch Later, lists, history and progress; "Who's watching?" on + launch, kids profiles (rated G/PG/TV-Y..TV-PG only, hold the avatar to leave). No PIN yet. ## Downloads From b35606a786e8f1824e2d337bc4f36fbf8a1ad51b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 17:07:44 +0000 Subject: [PATCH 3/6] Subtitles from SubSource, shared subtitle fetcher - SubSourceRepository (keyless): IMDb id from TMDB external_ids -> POST /v1/movie/search -> the first result's link (series: /series/x -> /subtitles/x/season-N) -> GET the list per language (English, plus the phone's language). Drops trailers, keeps the matching language, requires SxxEyy/1x05/Eyy for episodes and skips multi-episode packs. - Undocumented third-party behaviour: SubSource has no public API contract. This mirrors the calls its website makes to api.subsource.net/v1 (search, list, detail -> download_token, /subtitle/download/ returning a zip) and can break without notice. - CombinedSubtitleRepository merges OpenSubtitles and SubSource in parallel, dedupes, and keeps each result's source name (shown under the language in the picker). - SubtitleFetcher (data/subtitles) now does all subtitle downloads for the player and the cast proxy: SubSource token step, zip (largest subtitle entry), gzip, UTF-8 with Windows-1252 fallback. The player no longer opens connections from a composable. - Addic7ed was left out: it needs HTML scraping plus a settings toggle, so it wasn't cheap. Verified: compile, unit tests, and the SubSource flow run live from a script outside the repo (Breaking Bad S01E05 English: 61 season rows -> 4 episode matches -> zip with one .srt). Not run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H8GM7PfnBT2a3jjqSK47hS --- CLAUDE.md | 6 +- .../openstream/data/cast/CastMediaProxy.kt | 37 +--- .../repository/CombinedSubtitleRepository.kt | 29 +++ .../data/repository/SubSourceRepository.kt | 177 ++++++++++++++++++ .../data/subtitles/SubtitleFetcher.kt | 94 ++++++++++ .../ivor/openstream/di/RepositoryModule.kt | 4 +- .../presentation/player/PlayerScreen.kt | 3 +- .../presentation/player/PlayerViewModel.kt | 9 +- .../player/components/ExoPlayerView.kt | 63 ++----- 9 files changed, 342 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/com/ivor/openstream/data/repository/CombinedSubtitleRepository.kt create mode 100644 app/src/main/java/com/ivor/openstream/data/repository/SubSourceRepository.kt create mode 100644 app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleFetcher.kt diff --git a/CLAUDE.md b/CLAUDE.md index dca509e..ef63eca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,8 +87,10 @@ Rules: phone must stay on the TV's network. `PlayerScreen` swaps `ExoPlayerView` for `CastPlaybackView`. - **Player UI.** Controls in `PlayerControls`; settings and sources share `PlayerPanelHost` (bottom sheet inline, in-player side panel in fullscreen so immersive mode survives). -- **Subtitles.** `OpenSubtitlesRepository` (keyless legacy REST API) plus any the stream carries. - Files are gzipped; the player decompresses and strips promo cues. +- **Subtitles.** `CombinedSubtitleRepository`: `OpenSubtitlesRepository` (keyless legacy REST API) + and `SubSourceRepository` (keyless, mirrors subsource.net's own API: IMDb search -> list -> + download token -> zip), plus any the stream carries. `SubtitleFetcher` downloads and unwraps + them (gzip, zip, charset) for both the player and the cast proxy; promo cues are stripped. - **Network.** `AppDns` (DNS-over-HTTPS, default AdGuard, chosen in Settings) backs every OkHttp client and Coil's image loader (`OpenStreamApp`), because some ISPs block TMDB at the DNS level. Media3 playback/downloads and WebView sources still use the system resolver. diff --git a/app/src/main/java/com/ivor/openstream/data/cast/CastMediaProxy.kt b/app/src/main/java/com/ivor/openstream/data/cast/CastMediaProxy.kt index c30b2b1..30ad75b 100644 --- a/app/src/main/java/com/ivor/openstream/data/cast/CastMediaProxy.kt +++ b/app/src/main/java/com/ivor/openstream/data/cast/CastMediaProxy.kt @@ -16,10 +16,11 @@ import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.cache.Cache import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.ContentMetadata -import com.ivor.openstream.data.repository.OpenSubtitlesRepository import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT import com.ivor.openstream.data.streaming.ImagePrefixStrippingDataSource +import com.ivor.openstream.data.subtitles.SubtitleFetcher import com.ivor.openstream.data.subtitles.isSubtitleAd +import kotlinx.coroutines.runBlocking import com.ivor.openstream.data.subtitles.parseSubtitles import com.ivor.openstream.data.subtitles.toWebVtt import dagger.hilt.android.qualifiers.ApplicationContext @@ -41,7 +42,6 @@ import java.security.SecureRandom import java.util.Collections import java.util.concurrent.ExecutorService import java.util.concurrent.Executors -import java.util.zip.GZIPInputStream import javax.inject.Inject import javax.inject.Singleton @@ -64,7 +64,8 @@ import javax.inject.Singleton class CastMediaProxy @Inject constructor( @ApplicationContext private val context: Context, private val cache: Cache, - private val preferences: SharedPreferences + private val preferences: SharedPreferences, + private val subtitleFetcher: SubtitleFetcher ) { private val executor: ExecutorService = Executors.newCachedThreadPool { runnable -> Thread(runnable, "CastProxy").apply { isDaemon = true } @@ -350,32 +351,10 @@ class CastMediaProxy @Inject constructor( } private fun fetchSubtitleAsVtt(target: String, headers: Map): ByteArray { - val url = java.net.URL(target) - val connection = url.openConnection() as java.net.HttpURLConnection - try { - if (url.host.endsWith("opensubtitles.org")) { - // OpenSubtitles only asks for a User-Agent; the stream's Referer would be wrong there. - connection.setRequestProperty("User-Agent", OpenSubtitlesRepository.USER_AGENT) - } else { - connection.setRequestProperty("User-Agent", BROWSER_USER_AGENT) - headers.forEach { (name, value) -> connection.setRequestProperty(name, value) } - } - connection.connectTimeout = 10_000 - connection.readTimeout = 15_000 - if (connection.responseCode !in 200..299) throw IOException("HTTP ${connection.responseCode}") - val bytes = connection.inputStream.use { it.readBytes() } - val isGzip = bytes.size > 2 && bytes[0] == 0x1f.toByte() && bytes[1] == 0x8b.toByte() - val text = if (isGzip) { - GZIPInputStream(bytes.inputStream()).bufferedReader().use { it.readText() } - } else { - bytes.toString(Charsets.UTF_8) - } - val cues = parseSubtitles(text).filterNot { it.text.isSubtitleAd() } - if (cues.isEmpty()) throw IOException("No cues") - return cues.toWebVtt().toByteArray(Charsets.UTF_8) - } finally { - connection.disconnect() - } + val text = runBlocking { subtitleFetcher.fetchText(target, headers) } + val cues = parseSubtitles(text).filterNot { it.text.isSubtitleAd() } + if (cues.isEmpty()) throw IOException("No cues") + return cues.toWebVtt().toByteArray(Charsets.UTF_8) } private fun writeHead( diff --git a/app/src/main/java/com/ivor/openstream/data/repository/CombinedSubtitleRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/CombinedSubtitleRepository.kt new file mode 100644 index 0000000..af5b9ee --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/repository/CombinedSubtitleRepository.kt @@ -0,0 +1,29 @@ +package com.ivor.openstream.data.repository + +import com.ivor.openstream.data.remote.model.SubtitleDto +import com.ivor.openstream.domain.model.MediaIdentity +import com.ivor.openstream.domain.repository.SubtitleRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Every keyless subtitle source, searched in parallel: OpenSubtitles first, then SubSource. + * Each result keeps its source name, which the player shows under the language. + */ +@Singleton +class CombinedSubtitleRepository @Inject constructor( + private val openSubtitles: OpenSubtitlesRepository, + private val subSource: SubSourceRepository +) : SubtitleRepository { + override suspend fun search(identity: MediaIdentity): List = coroutineScope { + val fromOpenSubtitles = async { runCatching { openSubtitles.search(identity) }.getOrDefault(emptyList()) } + val fromSubSource = async { subSource.search(identity) } + (fromOpenSubtitles.await() + fromSubSource.await()) + .distinctBy { it.url } + // The same release uploaded to both sites: keep the first copy. + .distinctBy { listOf(it.language, it.release?.lowercase()?.trim(), it.isHearingImpaired).takeIf { _ -> it.release != null } ?: it.url } + .sortedBy { if (it.language == "en") 0 else 1 } + } +} diff --git a/app/src/main/java/com/ivor/openstream/data/repository/SubSourceRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/SubSourceRepository.kt new file mode 100644 index 0000000..0b8e9a5 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/repository/SubSourceRepository.kt @@ -0,0 +1,177 @@ +package com.ivor.openstream.data.repository + +import android.util.Log +import com.ivor.openstream.data.remote.model.SubtitleDto +import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT +import com.ivor.openstream.data.streaming.IdMappingService +import com.ivor.openstream.domain.model.MediaIdentity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.util.Locale +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * Subtitles from SubSource (subsource.net), keyless. SubSource has no public API contract: this + * mirrors what its own website calls (api.subsource.net/v1), which can change without notice. + * + * Search by IMDb id -> the title's `link` (`/subtitles/` for movies, `/series/` for + * shows, where a season is `/subtitles//season-N`) -> the list, filtered by language. A + * subtitle's URL here is its detail endpoint; [resolveDownloadUrl] trades that for a short-lived + * download token when the file is actually needed, and the file comes back as a zip. + */ +@Singleton +class SubSourceRepository @Inject constructor( + private val idMappingService: IdMappingService, + @Named("StreamingClient") private val client: OkHttpClient, + private val json: Json +) { + suspend fun search(identity: MediaIdentity): List = withContext(Dispatchers.IO) { + runCatching { searchOrThrow(identity) } + .onFailure { Log.w(TAG, "SubSource search failed: ${it.message}") } + .getOrDefault(emptyList()) + } + + private suspend fun searchOrThrow(identity: MediaIdentity): List { + val imdbId = idMappingService.enrich(identity).imdbId?.takeIf { it.startsWith("tt") } ?: return emptyList() + val isMovie = identity.tmdbType == "movie" + val link = findLink(imdbId) ?: return emptyList() + val listPath = if (isMovie) { + link + } else { + link.replaceFirst("/series/", "/subtitles/").trimEnd('/') + "/season-${identity.season}" + } + val episodePattern = if (isMovie) null else episodeRegex(identity.season, identity.episode) + + return coroutineScope { + languages().map { (name, code) -> + async { list(listPath, name).map { it to code } } + }.awaitAll().flatten() + } + .filter { (row, _) -> row.releaseType != "trailer" } + .filter { (row, _) -> episodePattern == null || episodePattern.containsMatchIn(row.releaseInfo) } + // Multi-episode packs ("S01E02-E05") hold several files; the wrong one could be picked. + .filter { (row, _) -> episodePattern == null || !EPISODE_RANGE.containsMatchIn(row.releaseInfo) } + .distinctBy { (row, _) -> row.id } + .groupBy { (row, _) -> row.language } + .flatMap { (_, sameLanguage) -> sameLanguage.take(MAX_PER_LANGUAGE) } + .map { (row, code) -> + SubtitleDto( + id = "ss_${row.id}", + url = "$API/subtitle/${row.link}", + display = languageLabel(row.language) + if (row.hearingImpaired) " (SDH)" else "", + language = code, + isHearingImpaired = row.hearingImpaired, + source = SOURCE_NAME, + release = row.releaseInfo + ) + } + } + + private fun findLink(imdbId: String): String? { + val body = buildJsonObject { + put("query", imdbId) + put("signal", "{}") + put("includeSeasons", false) + put("limit", 15) + }.toString() + val request = Request.Builder() + .url("$API/movie/search") + .header("User-Agent", BROWSER_USER_AGENT) + .post(body.toRequestBody("application/json".toMediaType())) + .build() + val text = client.newCall(request).execute().use { if (it.isSuccessful) it.body?.string() else null } ?: return null + return json.parseToJsonElement(text).jsonObject["results"]?.jsonArray + ?.firstOrNull()?.jsonObject?.get("link")?.jsonPrimitive?.contentOrNull + ?.takeIf { it.startsWith("/") } + } + + private fun list(path: String, language: String): List { + val request = Request.Builder() + .url("$API$path?language=$language") + .header("User-Agent", BROWSER_USER_AGENT) + .build() + val text = client.newCall(request).execute().use { if (it.isSuccessful) it.body?.string() else null } ?: return emptyList() + return json.parseToJsonElement(text).jsonObject["subtitles"]?.jsonArray.orEmpty().mapNotNull { element -> + val row = element as? JsonObject ?: return@mapNotNull null + Row( + id = row["id"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null, + language = row["language"]?.jsonPrimitive?.contentOrNull.orEmpty(), + releaseType = row["release_type"]?.jsonPrimitive?.contentOrNull, + releaseInfo = row["release_info"]?.jsonPrimitive?.contentOrNull.orEmpty(), + hearingImpaired = row["hearing_impaired"]?.jsonPrimitive?.intOrNull == 1, + link = row["link"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + ) + }.filter { it.language.equals(language, ignoreCase = true) } + } + + /** English, plus the phone's language when it isn't English: SubSource names languages in English. */ + private fun languages(): List> { + val device = Locale.getDefault() + val deviceName = device.getDisplayLanguage(Locale.ENGLISH).lowercase(Locale.ROOT) + return listOfNotNull( + "english" to "en", + (deviceName to device.language).takeIf { deviceName.isNotBlank() && device.language != "en" } + ) + } + + private fun languageLabel(name: String) = + name.replace('_', ' ').replaceFirstChar { it.titlecase(Locale.ENGLISH) } + + private data class Row( + val id: Int, + val language: String, + val releaseType: String?, + val releaseInfo: String, + val hearingImpaired: Boolean, + val link: String + ) + + companion object { + const val SOURCE_NAME = "SubSource" + private const val TAG = "SubSource" + private const val API = "https://api.subsource.net/v1" + private const val MAX_PER_LANGUAGE = 4 + private val EPISODE_RANGE = Regex("(?i)e\\d+\\s*-\\s*e?\\d+") + + /** `S01E05`, `s1.e5`, `1x05` or a bare `E05`, not followed by another digit. */ + internal fun episodeRegex(season: Int, episode: Int): Regex = Regex( + "(?i)(s0*$season[ ._-]*e0*$episode(?!\\d))|((? + if (!response.isSuccessful) throw IOException("SubSource returned HTTP ${response.code}") + response.body?.string().orEmpty() + } + val token = Json.parseToJsonElement(text).jsonObject["subtitle"]?.jsonObject + ?.get("download_token")?.jsonPrimitive?.contentOrNull + ?: throw IOException("SubSource gave no download token") + return "$API/subtitle/download/$token" + } + } +} diff --git a/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleFetcher.kt b/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleFetcher.kt new file mode 100644 index 0000000..dc9a2a0 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleFetcher.kt @@ -0,0 +1,94 @@ +package com.ivor.openstream.data.subtitles + +import com.ivor.openstream.data.repository.OpenSubtitlesRepository +import com.ivor.openstream.data.repository.SubSourceRepository +import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.ByteArrayInputStream +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.charset.CharacterCodingException +import java.nio.charset.CodingErrorAction +import java.util.zip.GZIPInputStream +import java.util.zip.ZipInputStream +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * Downloads a subtitle file as text, whatever it came wrapped in: OpenSubtitles' gzip files, + * SubSource's zip archives (behind a download token), or plain SRT/VTT/ASS from a stream's own + * host. Shared by the player and the cast proxy. + */ +@Singleton +class SubtitleFetcher @Inject constructor( + @Named("StreamingClient") private val client: OkHttpClient +) { + /** The subtitle's text. [headers] are the stream's (Referer...), sent only to the stream's hosts. */ + suspend fun fetchText(url: String, headers: Map = emptyMap()): String = withContext(Dispatchers.IO) { + val bytes = when { + SubSourceRepository.isSubSourceUrl(url) -> download(SubSourceRepository.resolveDownloadUrl(client, url), emptyMap()) + url.toHttpUrlOrNull()?.host?.endsWith("opensubtitles.org") == true -> + // OpenSubtitles only asks for a User-Agent; the stream's Referer would be wrong there. + download(url, mapOf("User-Agent" to OpenSubtitlesRepository.USER_AGENT)) + else -> download(url, mapOf("User-Agent" to BROWSER_USER_AGENT) + headers) + } + val text = decode(unwrap(bytes)) + if (text.isBlank()) throw IOException("Subtitle file is empty") + text + } + + private fun download(url: String, headers: Map): ByteArray { + val request = Request.Builder().url(url).apply { headers.forEach { (name, value) -> header(name, value) } }.build() + return client.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IOException("Subtitle server returned HTTP ${response.code}") + response.body?.bytes() ?: throw IOException("Empty subtitle response") + } + } + + /** Detects zip and gzip by their magic bytes rather than trusting URLs or headers. */ + private fun unwrap(bytes: ByteArray): ByteArray = when { + bytes.size > 4 && bytes[0] == 'P'.code.toByte() && bytes[1] == 'K'.code.toByte() && bytes[2] == 3.toByte() && bytes[3] == 4.toByte() -> + largestSubtitleInZip(bytes) + bytes.size > 2 && bytes[0] == 0x1f.toByte() && bytes[1] == 0x8b.toByte() -> + GZIPInputStream(ByteArrayInputStream(bytes)).use { it.readBytes() } + else -> bytes + } + + /** The biggest subtitle file in an archive (the full track rather than a forced-parts one). */ + private fun largestSubtitleInZip(bytes: ByteArray): ByteArray { + var best: ByteArray? = null + ZipInputStream(ByteArrayInputStream(bytes)).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + val name = entry.name.lowercase() + if (!entry.isDirectory && SUBTITLE_EXTENSIONS.any { name.endsWith(it) }) { + val content = zip.readBytes() + if (content.size > (best?.size ?: -1)) best = content + } + } + } + return best ?: throw IOException("No subtitle file in the archive") + } + + /** UTF-8 when the file is valid UTF-8, otherwise Windows-1252 (common for older SRTs). */ + private fun decode(bytes: ByteArray): String { + val strict = Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + val text = try { + strict.decode(ByteBuffer.wrap(bytes)).toString() + } catch (_: CharacterCodingException) { + String(bytes, charset("windows-1252")) + } + return text.removePrefix("") + } + + private companion object { + val SUBTITLE_EXTENSIONS = listOf(".srt", ".vtt", ".ass", ".ssa") + } +} diff --git a/app/src/main/java/com/ivor/openstream/di/RepositoryModule.kt b/app/src/main/java/com/ivor/openstream/di/RepositoryModule.kt index f9f256c..c139f11 100644 --- a/app/src/main/java/com/ivor/openstream/di/RepositoryModule.kt +++ b/app/src/main/java/com/ivor/openstream/di/RepositoryModule.kt @@ -4,7 +4,7 @@ import com.ivor.openstream.data.repository.AnimeRepositoryImpl import com.ivor.openstream.data.repository.DownloadRepositoryImpl import com.ivor.openstream.data.repository.WatchLaterRepositoryImpl import com.ivor.openstream.data.repository.WatchProgressRepositoryImpl -import com.ivor.openstream.data.repository.OpenSubtitlesRepository +import com.ivor.openstream.data.repository.CombinedSubtitleRepository import com.ivor.openstream.domain.repository.SubtitleRepository import com.ivor.openstream.data.extensions.ExtensionRepositoryImpl import com.ivor.openstream.domain.repository.AnimeRepository @@ -48,6 +48,6 @@ abstract class RepositoryModule { @Binds abstract fun bindSubtitleRepository( - openSubtitlesRepository: OpenSubtitlesRepository + combinedSubtitleRepository: CombinedSubtitleRepository ): SubtitleRepository } diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt index 1fc2657..71f72b8 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt @@ -478,7 +478,8 @@ fun PlayerScreen( }, onPlaybackReady = viewModel::onPlaybackReady, onCastClick = { showCastSheet = true }.takeIf { castStatus.supported }, - onPictureInPictureClick = enterPictureInPicture + onPictureInPictureClick = enterPictureInPicture, + loadSubtitleText = viewModel::loadSubtitleText ) } } else { diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt index 4e6d1a3..db8a4b7 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt @@ -47,6 +47,7 @@ import com.ivor.openstream.data.settings.AppSettings import com.ivor.openstream.data.repository.SkipSegment import com.ivor.openstream.data.repository.SkipTimesRepository import com.ivor.openstream.data.settings.AppSettingsStore +import com.ivor.openstream.data.subtitles.SubtitleFetcher import com.ivor.openstream.presentation.player.session.SleepTimer import com.ivor.openstream.presentation.player.session.CastError import com.ivor.openstream.presentation.player.session.CastStatus @@ -104,8 +105,14 @@ class PlayerViewModel @Inject constructor( private val json: Json, private val playbackSession: PlaybackSession, appSettingsStore: AppSettingsStore, - private val skipTimesRepository: SkipTimesRepository + private val skipTimesRepository: SkipTimesRepository, + private val subtitleFetcher: SubtitleFetcher ) : ViewModel() { + + /** A sideloaded subtitle's text, downloaded and unwrapped by the data layer. */ + suspend fun loadSubtitleText(url: String, headers: Map): String = + subtitleFetcher.fetchText(url, headers) + /** Intro/recap/credits times for the current anime episode; empty when unknown. */ private val _skipSegments = MutableStateFlow>(emptyList()) val skipSegments: StateFlow> = _skipSegments.asStateFlow() diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt b/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt index 9b56439..8ab5215 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt @@ -115,6 +115,7 @@ import androidx.compose.runtime.mutableIntStateOf import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT import com.ivor.openstream.data.repository.forDuration import com.ivor.openstream.data.subtitles.SubtitleCue +import com.ivor.openstream.data.repository.SubSourceRepository import com.ivor.openstream.data.subtitles.isSubtitleAd import com.ivor.openstream.data.subtitles.parseSubtitles @@ -178,7 +179,9 @@ fun ExoPlayerView( /** Opens the Cast device picker; null hides the Cast button. */ onCastClick: (() -> Unit)? = null, /** Pops the video out into picture-in-picture; null hides the button. */ - onPictureInPictureClick: (() -> Unit)? = null + onPictureInPictureClick: (() -> Unit)? = null, + /** Downloads a sideloaded subtitle as text (gzip/zip handled); throws when it can't. */ + loadSubtitleText: suspend (url: String, headers: Map) -> String = { _, _ -> throw IllegalStateException("No subtitle loader") } ) { val context = LocalContext.current val activity = remember(context) { @@ -337,47 +340,17 @@ fun ExoPlayerView( val urlStr = selectedSubtitle?.url if (urlStr != null) { subtitleLoadingState = SubtitleLoadingState.LOADING - withContext(Dispatchers.IO) { - try { - val url = java.net.URL(urlStr) - val connection = url.openConnection() as java.net.HttpURLConnection - if (url.host.endsWith("opensubtitles.org")) { - // OpenSubtitles only asks for a User-Agent; the stream's Referer would be wrong here. - connection.setRequestProperty("User-Agent", OpenSubtitlesRepository.USER_AGENT) - } else { - connection.setRequestProperty("User-Agent", BROWSER_USER_AGENT) - requestHeaders.forEach { (name, value) -> connection.setRequestProperty(name, value) } - } - connection.connectTimeout = 10000 - connection.readTimeout = 10000 - - if (connection.responseCode != 200) { - throw Exception("Server returned code ${connection.responseCode} (Subtitles might be restricted)") - } - - val bytes = connection.inputStream.use { it.readBytes() } - // OpenSubtitles serves gzip files; detect by magic bytes rather than trusting the URL. - val isGzip = bytes.size > 2 && bytes[0] == 0x1f.toByte() && bytes[1] == 0x8b.toByte() - val raw = if (isGzip) { - java.util.zip.GZIPInputStream(bytes.inputStream()).bufferedReader().use { it.readText() } - } else { - bytes.toString(Charsets.UTF_8) - } - Log.d("PlayerSubtitles", "Downloaded raw data: ${raw.take(100)}...") - - if (raw.trim().isEmpty()) { - throw Exception("Subtitle file is empty") - } - - manualCues = parseSubtitles(raw).filterNot { it.text.isSubtitleAd() } - subtitleLoadingState = if (manualCues.isNotEmpty()) SubtitleLoadingState.SUCCESS else SubtitleLoadingState.ERROR - - Log.i("PlayerSubtitles", "Parsed ${manualCues.size} cues for manual sync") - } catch (e: Exception) { - Log.e("PlayerSubtitles", "Failed to parse sideloaded subtitles: ${e.message}") - manualCues = emptyList() - subtitleLoadingState = SubtitleLoadingState.ERROR - } + try { + val raw = loadSubtitleText(urlStr, requestHeaders) + manualCues = withContext(Dispatchers.Default) { parseSubtitles(raw).filterNot { it.text.isSubtitleAd() } } + subtitleLoadingState = if (manualCues.isNotEmpty()) SubtitleLoadingState.SUCCESS else SubtitleLoadingState.ERROR + Log.i("PlayerSubtitles", "Parsed ${manualCues.size} cues for manual sync") + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + Log.e("PlayerSubtitles", "Failed to load sideloaded subtitles: ${e.message}") + manualCues = emptyList() + subtitleLoadingState = SubtitleLoadingState.ERROR } } else { manualCues = emptyList() @@ -385,8 +358,6 @@ fun ExoPlayerView( } } - - // Helper: parse available tracks from ExoPlayer fun parseTracksFromPlayer(tracks: Tracks) { val qualities = mutableListOf() @@ -1622,8 +1593,10 @@ private fun displayLanguageOrNull(code: String): String? { return name.takeIf { it.isNotBlank() && !it.equals(normalized, ignoreCase = true) } } +/** Files the player can't read itself (compressed, or behind a download token); loaded on demand. */ private fun SubtitleDto.isSideloadOnly(): Boolean = - source == OpenSubtitlesRepository.SOURCE_NAME || url.substringBefore('?').endsWith(".gz", ignoreCase = true) + source == OpenSubtitlesRepository.SOURCE_NAME || source == SubSourceRepository.SOURCE_NAME || + url.substringBefore('?').substringAfterLast('.').lowercase() in setOf("gz", "zip") /** Caption size in picture-in-picture relative to the user's chosen size. */ private const val PIP_CAPTION_SCALE = 0.55f From 395ea3132d3fd2bc53b6a5f713ab91e81600b13d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 17:09:58 +0000 Subject: [PATCH 4/6] Picture-in-picture: two configurable buttons beside play/pause - The PiP window now shows [left] [play/pause] [right] (three is what Android allows; devices that allow fewer keep play/pause only). Left and right are set in Settings -> Picture-in-picture: Back, Forward (by the seek step), Next episode (greyed out when there is none) or Skip intro (the AniSkip segment playing now, else the manual 85 s jump). - Defaults: Back and Forward. Choices are in backups. - Own vector icons for the PiP buttons instead of the old framework media drawables. Verified: compile. Not run on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H8GM7PfnBT2a3jjqSK47hS --- .../openstream/data/backup/LibraryBackup.kt | 13 +++- .../data/settings/AppSettingsStore.kt | 21 ++++++- .../presentation/player/PictureInPicture.kt | 62 ++++++++++++++++--- .../presentation/player/PlayerScreen.kt | 25 +++++++- .../player/components/ExoPlayerView.kt | 3 +- .../presentation/settings/SettingsScreen.kt | 28 +++++++++ .../settings/SettingsViewModel.kt | 5 ++ app/src/main/res/drawable/ic_pip_forward.xml | 10 +++ app/src/main/res/drawable/ic_pip_next.xml | 10 +++ app/src/main/res/drawable/ic_pip_pause.xml | 10 +++ app/src/main/res/drawable/ic_pip_play.xml | 10 +++ app/src/main/res/drawable/ic_pip_rewind.xml | 10 +++ app/src/main/res/drawable/ic_pip_skip.xml | 10 +++ docs/ROADMAP.md | 3 +- 14 files changed, 204 insertions(+), 16 deletions(-) create mode 100644 app/src/main/res/drawable/ic_pip_forward.xml create mode 100644 app/src/main/res/drawable/ic_pip_next.xml create mode 100644 app/src/main/res/drawable/ic_pip_pause.xml create mode 100644 app/src/main/res/drawable/ic_pip_play.xml create mode 100644 app/src/main/res/drawable/ic_pip_rewind.xml create mode 100644 app/src/main/res/drawable/ic_pip_skip.xml diff --git a/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt b/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt index 9cce9cb..f72ae14 100644 --- a/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt +++ b/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt @@ -13,6 +13,7 @@ import com.ivor.openstream.data.repository.ProfileRepository import com.ivor.openstream.data.settings.AppSettings import com.ivor.openstream.data.settings.AppSettingsStore import com.ivor.openstream.data.settings.DnsProvider +import com.ivor.openstream.data.settings.PipAction import com.ivor.openstream.data.settings.ThemeMode import kotlinx.coroutines.flow.first import kotlinx.serialization.Serializable @@ -263,7 +264,9 @@ private data class BackupSettings( val seekStepSeconds: Int? = null, val defaultSpeed: Float? = null, val autoPlayNext: Boolean? = null, - val downloadMaxHeight: Int? = null + val downloadMaxHeight: Int? = null, + val pipLeftAction: String? = null, + val pipRightAction: String? = null ) { fun applyTo(current: AppSettings) = current.copy( themeMode = ThemeMode.entries.firstOrNull { it.name == themeMode } ?: current.themeMode, @@ -273,7 +276,9 @@ private data class BackupSettings( seekStepSeconds = seekStepSeconds?.takeIf { it in AppSettings.SEEK_STEPS } ?: current.seekStepSeconds, defaultSpeed = defaultSpeed?.takeIf { it in AppSettings.DEFAULT_SPEEDS } ?: current.defaultSpeed, autoPlayNext = autoPlayNext ?: current.autoPlayNext, - downloadMaxHeight = downloadMaxHeight?.takeIf { it in AppSettings.DOWNLOAD_HEIGHTS } ?: current.downloadMaxHeight + downloadMaxHeight = downloadMaxHeight?.takeIf { it in AppSettings.DOWNLOAD_HEIGHTS } ?: current.downloadMaxHeight, + pipLeftAction = PipAction.entries.firstOrNull { it.name == pipLeftAction } ?: current.pipLeftAction, + pipRightAction = PipAction.entries.firstOrNull { it.name == pipRightAction } ?: current.pipRightAction ) } @@ -285,5 +290,7 @@ private fun AppSettings.toBackup() = BackupSettings( seekStepSeconds = seekStepSeconds, defaultSpeed = defaultSpeed, autoPlayNext = autoPlayNext, - downloadMaxHeight = downloadMaxHeight + downloadMaxHeight = downloadMaxHeight, + pipLeftAction = pipLeftAction.name, + pipRightAction = pipRightAction.name ) diff --git a/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt b/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt index e286107..78e38dd 100644 --- a/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt +++ b/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt @@ -26,6 +26,14 @@ enum class DnsProvider( GOOGLE("Google", "https://dns.google/dns-query", listOf("8.8.8.8", "8.8.4.4")) } +/** A button in the picture-in-picture window, beside play/pause. */ +enum class PipAction(val label: String) { + REWIND("Back"), + FORWARD("Forward"), + NEXT_EPISODE("Next"), + SKIP_INTRO("Skip intro") +} + data class AppSettings( val themeMode: ThemeMode = ThemeMode.SYSTEM, val dynamicColor: Boolean = true, @@ -38,7 +46,10 @@ data class AppSettings( /** Count down into the next episode when one ends. */ val autoPlayNext: Boolean = true, /** Tallest rendition a download picks from a master playlist. */ - val downloadMaxHeight: Int = 1080 + val downloadMaxHeight: Int = 1080, + /** Picture-in-picture buttons left and right of play/pause (Android shows three at most). */ + val pipLeftAction: PipAction = PipAction.REWIND, + val pipRightAction: PipAction = PipAction.FORWARD ) { companion object { val SEEK_STEPS = listOf(5, 10, 15, 30) @@ -82,6 +93,8 @@ class AppSettingsStore @Inject constructor( .putFloat(KEY_DEFAULT_SPEED, updated.defaultSpeed) .putBoolean(KEY_AUTO_PLAY_NEXT, updated.autoPlayNext) .putInt(KEY_DOWNLOAD_HEIGHT, updated.downloadMaxHeight) + .putString(KEY_PIP_LEFT, updated.pipLeftAction.name) + .putString(KEY_PIP_RIGHT, updated.pipRightAction.name) .apply() } @@ -98,7 +111,9 @@ class AppSettingsStore @Inject constructor( .takeIf { it in AppSettings.DEFAULT_SPEEDS } ?: defaults.defaultSpeed, autoPlayNext = prefs.getBoolean(KEY_AUTO_PLAY_NEXT, defaults.autoPlayNext), downloadMaxHeight = prefs.getInt(KEY_DOWNLOAD_HEIGHT, defaults.downloadMaxHeight) - .takeIf { it in AppSettings.DOWNLOAD_HEIGHTS } ?: defaults.downloadMaxHeight + .takeIf { it in AppSettings.DOWNLOAD_HEIGHTS } ?: defaults.downloadMaxHeight, + pipLeftAction = enumOrDefault(prefs.getString(KEY_PIP_LEFT, null), defaults.pipLeftAction), + pipRightAction = enumOrDefault(prefs.getString(KEY_PIP_RIGHT, null), defaults.pipRightAction) ) } @@ -115,6 +130,8 @@ class AppSettingsStore @Inject constructor( const val KEY_AUTO_PLAY_NEXT = "app_auto_play_next" const val KEY_DOWNLOAD_HEIGHT = "app_download_max_height" const val KEY_ACTIVE_PROFILE = "app_active_profile_id" + const val KEY_PIP_LEFT = "app_pip_left_action" + const val KEY_PIP_RIGHT = "app_pip_right_action" /** Matches ProfileEntity.DEFAULT_ID, the profile the Room migration seeds. */ const val DEFAULT_PROFILE_ID = 1L } diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/PictureInPicture.kt b/app/src/main/java/com/ivor/openstream/presentation/player/PictureInPicture.kt index 4f4cd05..f2580f6 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/PictureInPicture.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/PictureInPicture.kt @@ -23,8 +23,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.core.app.PictureInPictureModeChangedInfo import androidx.core.content.ContextCompat import androidx.core.util.Consumer +import com.ivor.openstream.R +import com.ivor.openstream.data.settings.PipAction private const val ACTION_TOGGLE_PLAYBACK = "com.ivor.openstream.action.PIP_TOGGLE_PLAYBACK" +private const val ACTION_PIP_BUTTON = "com.ivor.openstream.action.PIP_BUTTON" +private const val EXTRA_PIP_BUTTON = "button" private val VIDEO_ASPECT_RATIO = Rational(16, 9) private fun Context.findComponentActivity(): ComponentActivity? { @@ -77,23 +81,41 @@ fun rememberEnterPictureInPicture(): (() -> Unit)? { /** * Lets playback continue in a floating window when the user leaves the app mid-video. * Android 12+ enters automatically; older versions enter on the user-leave hint. - * The window carries a play/pause action so it is useful without reopening the app. + * The window carries play/pause in the middle and the two buttons chosen in Settings either side + * (Android shows three at most), so it is useful without reopening the app. */ @Composable fun PictureInPictureEffect( enabled: Boolean, isPlaying: Boolean, - onTogglePlayback: () -> Unit + onTogglePlayback: () -> Unit, + leftAction: PipAction = PipAction.REWIND, + rightAction: PipAction = PipAction.FORWARD, + seekStepSeconds: Int = 10, + /** False greys out "Next" (no next episode). */ + canGoNext: Boolean = false, + onAction: (PipAction) -> Unit = {} ) { val context = LocalContext.current val activity = context.findComponentActivity() ?: return val latestToggle by rememberUpdatedState(onTogglePlayback) + val latestAction by rememberUpdatedState(onAction) val canEnter = enabled && isPlaying - val params = remember(enabled, isPlaying) { + val params = remember(enabled, isPlaying, leftAction, rightAction, seekStepSeconds, canGoNext) { + val maxActions = runCatching { activity.maxNumPictureInPictureActions }.getOrDefault(1) + val actions = if (maxActions >= 3) { + listOf( + buttonAction(context, leftAction, seekStepSeconds, canGoNext), + playbackAction(context, isPlaying), + buttonAction(context, rightAction, seekStepSeconds, canGoNext) + ) + } else { + listOf(playbackAction(context, isPlaying)) + } PictureInPictureParams.Builder() .setAspectRatio(VIDEO_ASPECT_RATIO) - .setActions(listOf(playbackAction(context, isPlaying))) + .setActions(actions) .apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { setAutoEnterEnabled(canEnter) @@ -117,13 +139,18 @@ fun PictureInPictureEffect( DisposableEffect(Unit) { val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - if (intent.action == ACTION_TOGGLE_PLAYBACK) latestToggle() + when (intent.action) { + ACTION_TOGGLE_PLAYBACK -> latestToggle() + ACTION_PIP_BUTTON -> PipAction.entries + .firstOrNull { it.name == intent.getStringExtra(EXTRA_PIP_BUTTON) } + ?.let(latestAction) + } } } ContextCompat.registerReceiver( context, receiver, - IntentFilter(ACTION_TOGGLE_PLAYBACK), + IntentFilter(ACTION_TOGGLE_PLAYBACK).apply { addAction(ACTION_PIP_BUTTON) }, ContextCompat.RECEIVER_NOT_EXPORTED ) onDispose { @@ -148,9 +175,28 @@ private fun playbackAction(context: Context, isPlaying: Boolean): RemoteAction { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val (icon, label) = if (isPlaying) { - android.R.drawable.ic_media_pause to "Pause" + R.drawable.ic_pip_pause to "Pause" } else { - android.R.drawable.ic_media_play to "Play" + R.drawable.ic_pip_play to "Play" } return RemoteAction(Icon.createWithResource(context, icon), label, label, intent) } + +private fun buttonAction(context: Context, action: PipAction, seekStepSeconds: Int, canGoNext: Boolean): RemoteAction { + val intent = PendingIntent.getBroadcast( + context, + // One request code per button, or the PendingIntents would collapse into one. + 1 + action.ordinal, + Intent(ACTION_PIP_BUTTON).setPackage(context.packageName).putExtra(EXTRA_PIP_BUTTON, action.name), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val (icon, label) = when (action) { + PipAction.REWIND -> R.drawable.ic_pip_rewind to "Back ${seekStepSeconds}s" + PipAction.FORWARD -> R.drawable.ic_pip_forward to "Forward ${seekStepSeconds}s" + PipAction.NEXT_EPISODE -> R.drawable.ic_pip_next to "Next episode" + PipAction.SKIP_INTRO -> R.drawable.ic_pip_skip to "Skip intro" + } + return RemoteAction(Icon.createWithResource(context, icon), label, label, intent).apply { + isEnabled = action != PipAction.NEXT_EPISODE || canGoNext + } +} diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt index 71f72b8..2ea88df 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt @@ -102,6 +102,9 @@ import androidx.media3.common.util.UnstableApi import coil3.compose.AsyncImage import com.ivor.openstream.data.remote.model.SubtitleDto import com.ivor.openstream.presentation.player.components.ExoPlayerView +import com.ivor.openstream.presentation.player.components.MANUAL_SKIP_MS +import com.ivor.openstream.data.repository.forDuration +import com.ivor.openstream.data.settings.PipAction import com.ivor.openstream.presentation.player.components.CastDeviceSheet import com.ivor.openstream.presentation.player.components.CastPlaybackView import com.ivor.openstream.presentation.player.components.SourcesPageActions @@ -218,7 +221,27 @@ fun PlayerScreen( PictureInPictureEffect( enabled = videoUrl != null && !isCasting, isPlaying = isVideoPlaying, - onTogglePlayback = { togglePlaybackSignal++ } + onTogglePlayback = { togglePlaybackSignal++ }, + leftAction = appSettings.pipLeftAction, + rightAction = appSettings.pipRightAction, + seekStepSeconds = appSettings.seekStepSeconds, + canGoNext = nextEpisode != null, + onAction = { action -> + val player = viewModel.player + val position = player.currentPosition.coerceAtLeast(0L) + val stepMs = appSettings.seekStepSeconds * 1_000L + when (action) { + PipAction.REWIND -> player.seekTo((position - stepMs).coerceAtLeast(0L)) + PipAction.FORWARD -> player.seekTo(position + stepMs) + PipAction.NEXT_EPISODE -> nextEpisode?.let { onEpisodeClick(it.season, it.episode) } + PipAction.SKIP_INTRO -> { + // The segment playing now (AniSkip), else the same jump as the manual skip. + val segment = skipSegments.forDuration(player.duration.coerceAtLeast(0L)) + .firstOrNull { position >= it.startMs && position < it.endMs - 1_000 } + player.seekTo(segment?.endMs ?: (position + MANUAL_SKIP_MS)) + } + } + } ) // Trigger data fetch diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt b/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt index 8ab5215..92b86bd 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt @@ -1510,7 +1510,8 @@ private fun GestureIndicator( private const val BOOST_SPEED = 2f /** A typical opening's length, for titles AniSkip has no times for. */ -private const val MANUAL_SKIP_MS = 85_000L +/** How far the manual skip (and PiP "Skip intro" without AniSkip times) jumps. */ +const val MANUAL_SKIP_MS = 85_000L /** The manual skip is only offered this early in a video. */ private const val MANUAL_SKIP_WINDOW_MS = 10 * 60_000L diff --git a/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsScreen.kt index 096a5b4..616b8fc 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsScreen.kt @@ -26,6 +26,8 @@ import androidx.compose.material.icons.filled.Forward10 import androidx.compose.material.icons.filled.HighQuality import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.PictureInPictureAlt +import com.ivor.openstream.data.settings.PipAction import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Restore @@ -259,6 +261,32 @@ fun SettingsScreen( } } + item(key = "pip") { + SettingsGroup( + title = "Picture-in-picture", + footer = "Play/pause always sits in the middle. Android shows three buttons at most." + ) { + ChoiceRow( + index = 0, count = 2, + icon = Icons.Default.PictureInPictureAlt, + title = "Left button", + options = PipAction.entries, + selected = appSettings.pipLeftAction, + label = { it.label }, + onSelect = viewModel::setPipLeftAction + ) + ChoiceRow( + index = 1, count = 2, + icon = Icons.Default.PictureInPictureAlt, + title = "Right button", + options = PipAction.entries, + selected = appSettings.pipRightAction, + label = { it.label }, + onSelect = viewModel::setPipRightAction + ) + } + } + item(key = "downloads") { SettingsGroup(title = "Downloads") { ChoiceRow( diff --git a/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsViewModel.kt index 6d7168e..58bb9d5 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsViewModel.kt @@ -15,6 +15,7 @@ import com.ivor.openstream.data.repository.HiddenTitlesRepository import com.ivor.openstream.data.settings.AppSettings import com.ivor.openstream.data.settings.AppSettingsStore import com.ivor.openstream.data.settings.DnsProvider +import com.ivor.openstream.data.settings.PipAction import com.ivor.openstream.data.settings.ThemeMode import com.ivor.openstream.di.downloadRequirements import com.ivor.openstream.domain.repository.ExtensionRepository @@ -165,6 +166,10 @@ class SettingsViewModel @Inject constructor( fun setDownloadMaxHeight(height: Int) = appSettingsStore.update { it.copy(downloadMaxHeight = height) } + fun setPipLeftAction(action: PipAction) = appSettingsStore.update { it.copy(pipLeftAction = action) } + + fun setPipRightAction(action: PipAction) = appSettingsStore.update { it.copy(pipRightAction = action) } + fun setWifiOnlyDownloads(wifiOnly: Boolean) { appSettingsStore.update { it.copy(wifiOnlyDownloads = wifiOnly) } downloadManager.requirements = downloadRequirements(wifiOnly) diff --git a/app/src/main/res/drawable/ic_pip_forward.xml b/app/src/main/res/drawable/ic_pip_forward.xml new file mode 100644 index 0000000..e563e88 --- /dev/null +++ b/app/src/main/res/drawable/ic_pip_forward.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_pip_next.xml b/app/src/main/res/drawable/ic_pip_next.xml new file mode 100644 index 0000000..5fd9287 --- /dev/null +++ b/app/src/main/res/drawable/ic_pip_next.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_pip_pause.xml b/app/src/main/res/drawable/ic_pip_pause.xml new file mode 100644 index 0000000..10e8408 --- /dev/null +++ b/app/src/main/res/drawable/ic_pip_pause.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_pip_play.xml b/app/src/main/res/drawable/ic_pip_play.xml new file mode 100644 index 0000000..30ae04d --- /dev/null +++ b/app/src/main/res/drawable/ic_pip_play.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_pip_rewind.xml b/app/src/main/res/drawable/ic_pip_rewind.xml new file mode 100644 index 0000000..5dd1800 --- /dev/null +++ b/app/src/main/res/drawable/ic_pip_rewind.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_pip_skip.xml b/app/src/main/res/drawable/ic_pip_skip.xml new file mode 100644 index 0000000..89a27c5 --- /dev/null +++ b/app/src/main/res/drawable/ic_pip_skip.xml @@ -0,0 +1,10 @@ + + + + diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1bfd72d..32472e1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -55,7 +55,8 @@ source failover, in-app updates with release notes, dynamic color. playlists, PNG-prefix stripping and WebVTT subtitles; downloads cast from the cache. Playback moves to the TV at the current position and back to the phone (paused) on disconnect; progress, mini player, up next, sleep timer, TV subtitles and volume keys work while casting. -- [x] **Picture-in-picture button** in the player's top bar, besides auto-entering on Home. +- [x] **Picture-in-picture button** in the player's top bar, besides auto-entering on Home. The + PiP window has play/pause plus two buttons picked in Settings (back, forward, next, skip intro). - [ ] **Seekbar thumbnail previews.** Only when the stream ships trick-play images; low priority. Fixed: the brightness gesture now starts from the current brightness, and leaving the player hands From 338d49f4b3f46e08f35b9833f030d018079e99bc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 17:19:24 +0000 Subject: [PATCH 5/6] Anime sources: FourAnimo and AnimeGG Probed all five requested sites with one-off scripts outside the repo first: - FourAnimo (cdn.4animo.xyz): works. Embeds by AniList id + episode for sub and dub; the embed's getSources token returns HLS, 9 caption tracks and intro/outro times. Engine `fouranimo`. - AnimeGG (animegg.org): works. Search -> /series/ -> /-episode-N -> subbed/dubbed embeds listing /play//video.mp4 per quality (360-1080p), redirecting to vidcache.net. Engine `animegg`. The final vidcache hop (port 8161) isn't reachable from the build sandbox, so only the redirect was confirmed. - AniNeko (vidhawk.buzz): dropped. Tickets resolve, but VidHawk's own HLS proxy answers 502 "upstream 403, cf_waf" from hls.1embed.buzz for every server, title and audio tried. - AniBD (animex.one): dropped. Its only stream API is download links (animepahe short links, already covered by the animepahe engine); playback uses third-party flixcloud embeds, and playeng.animeapps.top isn't referenced by the client. - AnimeDekho (animedekho.app): dropped. Episodes sit behind a gplinks/cuty.io "24hr verify" ad gate; getting past it would mean bypassing their access gate. Plumbing: - VideoServer gains `mimeType` (HLS set explicitly for FourAnimo's /p?t= URLs, used by the player's MediaItem and by downloads) and `skipSegments` (source intro/outro, preferred over AniSkip in the player and PiP "Skip intro"). Per-source headers already reached Media3 for playback (session ResolvingDataSource) and downloads (DownloadRequestHeaderStore). - SkipSegment/SkipType moved to domain/model. - Catalog: two new entries (status Beta) in extensions/index.json, byte-identical bundled copy. Undocumented third-party behaviour relied on: FourAnimo tokens are bound to the client IP and want the player host as Referer; AnimeGG's play links want Referer + Origin + Chrome client hints. Neither site documents any of this. Verified: compile, unit tests (52, incl. OfficialCatalogTest), and both engines' request/regex flow against the live sites (Frieren). Not played on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H8GM7PfnBT2a3jjqSK47hS --- CLAUDE.md | 4 +- .../main/assets/extensions/official-repo.json | 36 ++++ .../data/repository/DownloadRepositoryImpl.kt | 4 +- .../data/repository/SkipTimesRepository.kt | 24 +-- .../streaming/ExtensionProviderRegistry.kt | 13 ++ .../streaming/anime/MoreAnimeProviders.kt | 196 ++++++++++++++++++ .../domain/model/ExtensionMarketplace.kt | 6 +- .../openstream/domain/model/SkipSegment.kt | 23 ++ .../openstream/domain/model/VideoServer.kt | 15 +- .../presentation/player/PlayerScreen.kt | 11 +- .../presentation/player/PlayerViewModel.kt | 2 +- .../player/components/ExoPlayerView.kt | 12 +- extensions/index.json | 36 ++++ 13 files changed, 344 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/com/ivor/openstream/data/streaming/anime/MoreAnimeProviders.kt create mode 100644 app/src/main/java/com/ivor/openstream/domain/model/SkipSegment.kt diff --git a/CLAUDE.md b/CLAUDE.md index ef63eca..f4cdbea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,9 @@ Rules: Engines: `vidking-direct` (`VidkingDirectApi`, encrypted payload, prefers the master playlist so quality switches in-player), `web-embed` and `vidking-webview` (`WebEmbedResolver`, hidden WebView that records media requests). - Anime engines (`data/streaming/anime`): `anikoto`, `reanime`, `animepahe`. `AnimeEpisodeMapper` + Anime engines (`data/streaming/anime`): `anikoto`, `reanime`, `animepahe`, `fouranimo`, `animegg`. + `VideoServer` can carry a MIME hint (HLS for URLs without `.m3u8`) and the source's own intro/outro + times, which the player prefers over AniSkip. `AnimeEpisodeMapper` maps TMDB season/episode to an AniList episode (ani.zip + AniList GraphQL, both keyless); megaplay embeds decrypt with a fixed AES key; `ImagePrefixStrippingDataSource` strips the fake PNG header some anime CDNs put before TS segments. diff --git a/app/src/main/assets/extensions/official-repo.json b/app/src/main/assets/extensions/official-repo.json index 64f2431..9ed8de0 100644 --- a/app/src/main/assets/extensions/official-repo.json +++ b/app/src/main/assets/extensions/official-repo.json @@ -230,6 +230,42 @@ "priority": 11 } }, + { + "id": "fouranimo", + "name": "FourAnimo", + "description": "Anime only. Sub and dub in HLS, found by AniList id, with captions and intro/outro times.", + "version": "1.0.0", + "versionCode": 1, + "apiVersion": 1, + "authors": ["OpenStream"], + "language": "Multi", + "tags": ["anime", "sub", "dub", "official"], + "status": 3, + "updatedAt": "2026-09-28", + "engine": { + "type": "fouranimo", + "endpoint": "https://cdn.4animo.xyz", + "priority": 12 + } + }, + { + "id": "animegg", + "name": "AnimeGG", + "description": "Anime only. Sub and dub as MP4 files up to 1080p.", + "version": "1.0.0", + "versionCode": 1, + "apiVersion": 1, + "authors": ["OpenStream"], + "language": "Multi", + "tags": ["anime", "sub", "dub", "official"], + "status": 3, + "updatedAt": "2026-09-28", + "engine": { + "type": "animegg", + "endpoint": "https://www.animegg.org", + "priority": 13 + } + }, { "id": "jett", "name": "Jett", diff --git a/app/src/main/java/com/ivor/openstream/data/repository/DownloadRepositoryImpl.kt b/app/src/main/java/com/ivor/openstream/data/repository/DownloadRepositoryImpl.kt index ba1ceb7..99230e0 100644 --- a/app/src/main/java/com/ivor/openstream/data/repository/DownloadRepositoryImpl.kt +++ b/app/src/main/java/com/ivor/openstream/data/repository/DownloadRepositoryImpl.kt @@ -184,7 +184,7 @@ class DownloadRepositoryImpl @Inject constructor( headerStore.register(server.url, server.headers) val request = DownloadRequest.Builder(target.id, Uri.parse(streamUrl)) - .setMimeType(if (streamUrl.isHls()) MimeTypes.APPLICATION_M3U8 else null) + .setMimeType(if (server.isHls || streamUrl.isHls()) MimeTypes.APPLICATION_M3U8 else null) .build() dao.insertDownload( @@ -216,7 +216,7 @@ class DownloadRepositoryImpl @Inject constructor( * separate renditions (then keep the master). */ private fun singleRenditionUrl(server: VideoServer): String { - if (!server.url.isHls()) return server.url + if (!server.isHls && !server.url.isHls()) return server.url val body = runCatching { client.newCall( Request.Builder() diff --git a/app/src/main/java/com/ivor/openstream/data/repository/SkipTimesRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/SkipTimesRepository.kt index f241bd8..672c693 100644 --- a/app/src/main/java/com/ivor/openstream/data/repository/SkipTimesRepository.kt +++ b/app/src/main/java/com/ivor/openstream/data/repository/SkipTimesRepository.kt @@ -4,6 +4,8 @@ import android.util.Log import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT import com.ivor.openstream.data.streaming.anime.AnimeEpisodeMapper import com.ivor.openstream.domain.model.MediaIdentity +import com.ivor.openstream.domain.model.SkipSegment +import com.ivor.openstream.domain.model.SkipType import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @@ -20,28 +22,6 @@ import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton -enum class SkipType(val label: String) { - INTRO("Skip intro"), - RECAP("Skip recap"), - CREDITS("Skip credits") -} - -/** - * A stretch of an episode the player offers to jump past. [episodeLengthMs] is the length of the - * file the submitter timed (0 when unknown); cuts of different lengths have different times. - */ -data class SkipSegment(val type: SkipType, val startMs: Long, val endMs: Long, val episodeLengthMs: Long = 0L) - -/** - * The segments that fit a video [durationMs] long: per type, the submission timed on the file - * closest in length. Before the duration is known the first submission of each type is used. - */ -fun List.forDuration(durationMs: Long): List = - groupBy { it.type }.values.mapNotNull { sameType -> - if (durationMs <= 0L) sameType.firstOrNull() - else sameType.minByOrNull { if (it.episodeLengthMs > 0) kotlin.math.abs(it.episodeLengthMs - durationMs) else Long.MAX_VALUE / 2 } - }.filter { durationMs <= 0L || it.startMs < durationMs }.sortedBy { it.startMs } - /** * Intro, recap and credits times for anime from AniSkip, a community database keyed by MyAnimeList * id and the episode number within that MAL entry. diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt b/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt index 74692e1..9d4b9f5 100644 --- a/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt +++ b/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt @@ -3,6 +3,8 @@ package com.ivor.openstream.data.streaming import com.ivor.openstream.data.streaming.anime.AnikotoProvider import com.ivor.openstream.data.streaming.anime.AnimeEpisodeMapper import com.ivor.openstream.data.streaming.anime.AnimePaheProvider +import com.ivor.openstream.data.streaming.anime.AnimeGGProvider +import com.ivor.openstream.data.streaming.anime.FourAnimoProvider import com.ivor.openstream.data.streaming.anime.AnimeSiteSpec import com.ivor.openstream.data.streaming.anime.CloudflareClearance import com.ivor.openstream.data.streaming.anime.MegaplayExtractor @@ -125,6 +127,17 @@ class ExtensionProviderRegistry @Inject constructor( json = json, clearance = cloudflareClearance ) + ExtensionEngineType.FOURANIMO -> FourAnimoProvider( + spec = animeSiteSpec(manifest), + mapper = animeEpisodeMapper, + client = streamingClient, + json = json + ) + ExtensionEngineType.ANIMEGG -> AnimeGGProvider( + spec = animeSiteSpec(manifest), + mapper = animeEpisodeMapper, + client = streamingClient + ) ExtensionEngineType.UNSUPPORTED -> return null } diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/anime/MoreAnimeProviders.kt b/app/src/main/java/com/ivor/openstream/data/streaming/anime/MoreAnimeProviders.kt new file mode 100644 index 0000000..eb8ef70 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/streaming/anime/MoreAnimeProviders.kt @@ -0,0 +1,196 @@ +package com.ivor.openstream.data.streaming.anime + +import com.ivor.openstream.domain.model.HLS_MIME_TYPE +import com.ivor.openstream.domain.model.SkipSegment +import com.ivor.openstream.domain.model.SkipType +import com.ivor.openstream.domain.model.StreamAudio +import com.ivor.openstream.domain.model.StreamQuality +import com.ivor.openstream.domain.model.StreamSubtitle +import com.ivor.openstream.domain.model.VideoServer +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap + +/** + * FourAnimo (4animo.xyz): its player host (`endpoint`, cdn.4animo.xyz) embeds by AniList id and + * episode, for sub and dub. The embed page carries a one-time `getSources` token; the JSON it + * returns has the HLS playlist, caption tracks and intro/outro times. + * + * Undocumented behaviour relied on: tokens are bound to the client's IP (a phone keeps one; a + * connection from another IP gets "invalid token"), and playlists/segments want the player host + * as Referer. Streams are always HLS behind `/p?t=` URLs, so the MIME type is set explicitly. + */ +class FourAnimoProvider( + spec: AnimeSiteSpec, + mapper: AnimeEpisodeMapper, + private val client: OkHttpClient, + private val json: Json +) : AnimeSiteProvider(spec, mapper) { + + override suspend fun streams(ref: AnimeEpisodeRef): List = coroutineScope { + listOf(StreamAudio.SUB to "sub", StreamAudio.DUB to "dub").map { (audio, type) -> + async { runCatching { stream(ref, audio, type) }.getOrNull() } + }.awaitAll().filterNotNull() + } + + private fun stream(ref: AnimeEpisodeRef, audio: StreamAudio, type: String): VideoServer? { + val embed = "$base/embed/ani/${ref.anilistId}/${ref.episode}/$type?k=1" + val page = client.fetch(embed, mapOf("Referer" to SITE)) + val sourcesPath = Regex("""sourcesUrl\s*=\s*'([^']+)'""").find(page)?.groupValues?.get(1) ?: return null + val data = json.parseToJsonElement(client.fetch(absolute(sourcesPath), mapOf("Referer" to embed))).jsonObject + val file = (data["sources"] as? JsonArray)?.firstOrNull()?.jsonObject?.get("file") + ?.jsonPrimitive?.contentOrNull ?: return null + val headers = mapOf("Referer" to "$base/", "Origin" to base) + val url = absolute(file) + val playlist = runCatching { client.fetch(url, headers) }.getOrNull() ?: return null + if (!playlist.trimStart().startsWith("#EXTM3U")) return null + + val subtitles = (data["tracks"] as? JsonArray).orEmpty().mapNotNull { element -> + val track = element as? JsonObject ?: return@mapNotNull null + if (track["kind"]?.jsonPrimitive?.contentOrNull !in setOf(null, "captions", "subtitles")) return@mapNotNull null + val label = track["label"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null + StreamSubtitle( + url = absolute(track["file"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null), + label = label, + language = languageCode(label), + headers = headers + ) + } + val server = data["server"]?.jsonPrimitive?.contentOrNull ?: "HD-1" + return VideoServer( + id = "$id-$type-${ref.anilistId}-${ref.episode}", + providerId = id, + providerName = spec.name, + name = "$server · ${audio.label}", + url = url, + quality = bestPlaylistQuality(playlist), + audio = audio, + headers = headers, + subtitles = subtitles, + audioLanguage = if (audio == StreamAudio.DUB) "English" else "Japanese", + mimeType = HLS_MIME_TYPE, + skipSegments = listOfNotNull( + segment(data["intro"], SkipType.INTRO), + segment(data["outro"], SkipType.CREDITS) + ) + ) + } + + private fun segment(value: Any?, type: SkipType): SkipSegment? { + val range = value as? JsonObject ?: return null + val start = range["start"]?.jsonPrimitive?.doubleOrNull ?: return null + val end = range["end"]?.jsonPrimitive?.doubleOrNull ?: return null + return SkipSegment(type, (start * 1000).toLong(), (end * 1000).toLong()).takeIf { it.endMs > it.startMs } + } + + private fun absolute(path: String) = if (path.startsWith("http")) path else "$base/${path.trimStart('/')}" + + private companion object { + const val SITE = "https://4animo.xyz/" + } +} + +/** + * AnimeGG (animegg.org): sub and dub as progressive MP4s. Search by title, confirm the series page, + * then the episode page (`/-episode-N`) embeds one player per version whose page lists a + * `/play//video.mp4` link per quality; those redirect to the vidcache CDN. + * + * Undocumented behaviour relied on: the play links and CDN want Referer and Origin set to the + * site plus desktop Chrome client hints, and the redirect target is on a non-standard port. + */ +class AnimeGGProvider( + spec: AnimeSiteSpec, + mapper: AnimeEpisodeMapper, + private val client: OkHttpClient +) : AnimeSiteProvider(spec, mapper) { + private val slugs = ConcurrentHashMap() + + private val playHeaders: Map + get() = mapOf( + "Referer" to "$base/", + "Origin" to base, + "sec-ch-ua" to "\"Chromium\";v=\"126\", \"Google Chrome\";v=\"126\", \"Not-A.Brand\";v=\"99\"", + "sec-ch-ua-mobile" to "?0", + "sec-ch-ua-platform" to "\"Windows\"" + ) + + override suspend fun streams(ref: AnimeEpisodeRef): List { + val slug = seriesSlug(ref) ?: return emptyList() + val episodeUrl = "$base/$slug-episode-${ref.episode}" + val page = client.fetch(episodeUrl, mapOf("Referer" to "$base/series/$slug")) + // Each tab: data-id (the embed) and data-version (subbed, dubbed, raw). + val tabs = Regex("""]*data-id='(\d+)'[^>]*data-version="(\w+)"""").findAll(page) + .map { it.groupValues[1] to it.groupValues[2] } + .distinct() + .toList() + return coroutineScope { + tabs.map { (embedId, version) -> + async { runCatching { embedStreams(embedId, version, episodeUrl) }.getOrDefault(emptyList()) } + }.awaitAll().flatten() + } + } + + private fun embedStreams(embedId: String, version: String, episodeUrl: String): List { + val audio = when (version.lowercase(Locale.ROOT)) { + "dubbed" -> StreamAudio.DUB + "subbed" -> StreamAudio.SUB + else -> return emptyList() // Raw: no subtitles at all. + } + val embed = "$base/embed/$embedId" + val html = client.fetch(embed, mapOf("Referer" to episodeUrl)) + val sources = Regex("""file:\s*"(/play/[^"]+)",\s*label:\s*"(\d+)p"""").findAll(html) + .map { it.groupValues[1] to it.groupValues[2].toInt() } + .sortedByDescending { it.second } + .toList() + return sources.map { (path, height) -> + VideoServer( + id = "$id-$embedId-$height", + providerId = id, + providerName = spec.name, + name = "Animegg ${height}p · ${audio.label}", + url = "$base$path", + quality = StreamQuality.parse("${height}p"), + audio = audio, + headers = playHeaders + ("Referer" to embed), + audioLanguage = if (audio == StreamAudio.DUB) "English" else "Japanese", + mimeType = "video/mp4" + ) + } + } + + /** The series slug, taken from search results and matched against the AniList titles. */ + private fun seriesSlug(ref: AnimeEpisodeRef): String? { + slugs[ref.anilistId]?.let { return it } + for (title in ref.titles) { + val html = client.fetch("$base/search/?q=${title.urlEncoded()}", mapOf("Referer" to "$base/")) + val found = Regex("""href="/series/([a-z0-9-]+)"""").findAll(html).map { it.groupValues[1] }.distinct().toList() + val wanted = slugify(title) + val slug = found.firstOrNull { it == wanted } ?: found.firstOrNull() ?: continue + slugs[ref.anilistId] = slug + return slug + } + return null + } + + private fun slugify(title: String) = title.lowercase(Locale.ROOT) + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') +} + +/** ISO 639-1 code for an English language name such as "Portuguese (Brazil)"; null when unknown. */ +internal fun languageCode(label: String): String? { + val name = label.substringBefore('(').trim().lowercase(Locale.ROOT) + return Locale.getAvailableLocales() + .firstOrNull { it.getDisplayLanguage(Locale.ENGLISH).lowercase(Locale.ROOT) == name } + ?.language +} diff --git a/app/src/main/java/com/ivor/openstream/domain/model/ExtensionMarketplace.kt b/app/src/main/java/com/ivor/openstream/domain/model/ExtensionMarketplace.kt index 55693c1..6760eee 100644 --- a/app/src/main/java/com/ivor/openstream/domain/model/ExtensionMarketplace.kt +++ b/app/src/main/java/com/ivor/openstream/domain/model/ExtensionMarketplace.kt @@ -32,6 +32,8 @@ enum class ExtensionEngineType(val key: String) { ANIKOTO("anikoto"), REANIME("reanime"), ANIMEPAHE("animepahe"), + FOURANIMO("fouranimo"), + ANIMEGG("animegg"), UNSUPPORTED("unsupported"); companion object { @@ -58,7 +60,9 @@ data class ExtensionEngine( listOfNotNull(movieUrl, tvUrl).any { it.startsWith("https://") } ExtensionEngineType.ANIKOTO, ExtensionEngineType.REANIME, - ExtensionEngineType.ANIMEPAHE -> endpoint.startsWith("https://") + ExtensionEngineType.ANIMEPAHE, + ExtensionEngineType.FOURANIMO, + ExtensionEngineType.ANIMEGG -> endpoint.startsWith("https://") ExtensionEngineType.UNSUPPORTED -> false } diff --git a/app/src/main/java/com/ivor/openstream/domain/model/SkipSegment.kt b/app/src/main/java/com/ivor/openstream/domain/model/SkipSegment.kt new file mode 100644 index 0000000..cb5cddc --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/domain/model/SkipSegment.kt @@ -0,0 +1,23 @@ +package com.ivor.openstream.domain.model + +enum class SkipType(val label: String) { + INTRO("Skip intro"), + RECAP("Skip recap"), + CREDITS("Skip credits") +} + +/** + * A stretch of an episode the player offers to jump past. [episodeLengthMs] is the length of the + * file the submitter timed (0 when unknown); cuts of different lengths have different times. + */ +data class SkipSegment(val type: SkipType, val startMs: Long, val endMs: Long, val episodeLengthMs: Long = 0L) + +/** + * The segments that fit a video [durationMs] long: per type, the submission timed on the file + * closest in length. Before the duration is known the first submission of each type is used. + */ +fun List.forDuration(durationMs: Long): List = + groupBy { it.type }.values.mapNotNull { sameType -> + if (durationMs <= 0L) sameType.firstOrNull() + else sameType.minByOrNull { if (it.episodeLengthMs > 0) kotlin.math.abs(it.episodeLengthMs - durationMs) else Long.MAX_VALUE / 2 } + }.filter { durationMs <= 0L || it.startMs < durationMs }.sortedBy { it.startMs } diff --git a/app/src/main/java/com/ivor/openstream/domain/model/VideoServer.kt b/app/src/main/java/com/ivor/openstream/domain/model/VideoServer.kt index da12a6f..a17cece 100644 --- a/app/src/main/java/com/ivor/openstream/domain/model/VideoServer.kt +++ b/app/src/main/java/com/ivor/openstream/domain/model/VideoServer.kt @@ -13,16 +13,27 @@ data class VideoServer( val isDownloadable: Boolean = true, /** Spoken language when the source says so (dub routes), e.g. "English" or "Hindi". */ val audioLanguage: String? = null, - val resolvedAt: Long = System.currentTimeMillis() + val resolvedAt: Long = System.currentTimeMillis(), + /** Container hint (for example `application/x-mpegURL`) when the URL doesn't say; skips sniffing. */ + val mimeType: String? = null, + /** Intro/outro times the source itself provides; preferred over AniSkip. */ + val skipSegments: List = emptyList() ) { val isDub: Boolean get() = audio == StreamAudio.DUB + val isHls: Boolean + get() = mimeType.equals(HLS_MIME_TYPE, ignoreCase = true) || + url.substringBefore('?').endsWith(".m3u8", ignoreCase = true) + val streamType: String get() = when { - url.substringBefore('?').endsWith(".m3u8", ignoreCase = true) -> "HLS" + isHls -> "HLS" url.substringBefore('?').endsWith(".mpd", ignoreCase = true) -> "DASH" url.substringBefore('?').endsWith(".mp4", ignoreCase = true) -> "MP4" else -> "STREAM" } } + +/** Media3's HLS MIME type (MimeTypes.APPLICATION_M3U8), without depending on Media3 here. */ +const val HLS_MIME_TYPE = "application/x-mpegURL" diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt index 2ea88df..5c1db9f 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerScreen.kt @@ -103,7 +103,7 @@ import coil3.compose.AsyncImage import com.ivor.openstream.data.remote.model.SubtitleDto import com.ivor.openstream.presentation.player.components.ExoPlayerView import com.ivor.openstream.presentation.player.components.MANUAL_SKIP_MS -import com.ivor.openstream.data.repository.forDuration +import com.ivor.openstream.domain.model.forDuration import com.ivor.openstream.data.settings.PipAction import com.ivor.openstream.presentation.player.components.CastDeviceSheet import com.ivor.openstream.presentation.player.components.CastPlaybackView @@ -236,7 +236,8 @@ fun PlayerScreen( PipAction.NEXT_EPISODE -> nextEpisode?.let { onEpisodeClick(it.season, it.episode) } PipAction.SKIP_INTRO -> { // The segment playing now (AniSkip), else the same jump as the manual skip. - val segment = skipSegments.forDuration(player.duration.coerceAtLeast(0L)) + val segments = activeServer?.skipSegments?.takeIf { it.isNotEmpty() } ?: skipSegments + val segment = segments.forDuration(player.duration.coerceAtLeast(0L)) .firstOrNull { position >= it.startMs && position < it.endMs - 1_000 } player.seekTo(segment?.endMs ?: (position + MANUAL_SKIP_MS)) } @@ -476,7 +477,8 @@ fun PlayerScreen( seekStepSeconds = appSettings.seekStepSeconds, preferredSubtitleLanguage = preferredSubtitleLanguage, onSubtitleLanguageChosen = viewModel::setPreferredSubtitleLanguage, - skipSegments = skipSegments, + // Times from the source itself beat AniSkip's crowd-sourced ones. + skipSegments = activeServer?.skipSegments?.takeIf { it.isNotEmpty() } ?: skipSegments, episodes = if (mediaType == "movie") emptyList() else seasonEpisodes, currentEpisodeNumber = episode, episodeProgress = episodeProgress, @@ -502,7 +504,8 @@ fun PlayerScreen( onPlaybackReady = viewModel::onPlaybackReady, onCastClick = { showCastSheet = true }.takeIf { castStatus.supported }, onPictureInPictureClick = enterPictureInPicture, - loadSubtitleText = viewModel::loadSubtitleText + loadSubtitleText = viewModel::loadSubtitleText, + mimeType = activeServer?.mimeType.takeIf { downloadId == null } ) } } else { diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt index db8a4b7..6f04bb4 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt @@ -44,7 +44,7 @@ import androidx.media3.exoplayer.ExoPlayer import com.ivor.openstream.presentation.player.session.NowPlaying import com.ivor.openstream.presentation.player.session.PlaybackSession import com.ivor.openstream.data.settings.AppSettings -import com.ivor.openstream.data.repository.SkipSegment +import com.ivor.openstream.domain.model.SkipSegment import com.ivor.openstream.data.repository.SkipTimesRepository import com.ivor.openstream.data.settings.AppSettingsStore import com.ivor.openstream.data.subtitles.SubtitleFetcher diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt b/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt index 92b86bd..366a0a9 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.graphics.graphicsLayer import androidx.activity.compose.BackHandler import com.ivor.openstream.presentation.player.session.SleepTimer -import com.ivor.openstream.data.repository.SkipSegment +import com.ivor.openstream.domain.model.SkipSegment import com.ivor.openstream.data.remote.model.EpisodeDto import com.ivor.openstream.domain.model.WatchProgress import androidx.compose.animation.slideInHorizontally @@ -113,7 +113,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.runtime.mutableIntStateOf import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT -import com.ivor.openstream.data.repository.forDuration +import com.ivor.openstream.domain.model.forDuration import com.ivor.openstream.data.subtitles.SubtitleCue import com.ivor.openstream.data.repository.SubSourceRepository import com.ivor.openstream.data.subtitles.isSubtitleAd @@ -181,7 +181,9 @@ fun ExoPlayerView( /** Pops the video out into picture-in-picture; null hides the button. */ onPictureInPictureClick: (() -> Unit)? = null, /** Downloads a sideloaded subtitle as text (gzip/zip handled); throws when it can't. */ - loadSubtitleText: suspend (url: String, headers: Map) -> String = { _, _ -> throw IllegalStateException("No subtitle loader") } + loadSubtitleText: suspend (url: String, headers: Map) -> String = { _, _ -> throw IllegalStateException("No subtitle loader") }, + /** Container hint from the source (HLS for hosts whose URLs don't end in .m3u8). */ + mimeType: String? = null ) { val context = LocalContext.current val activity = remember(context) { @@ -603,7 +605,7 @@ fun ExoPlayerView( // CASE 1: Video URL changed (Episode switch) -> Full Reset if (currentUri != newUri) { applyRequestHeaders(requestHeaders) - val mediaItemBuilder = MediaItem.Builder().setUri(videoUrl) + val mediaItemBuilder = MediaItem.Builder().setUri(videoUrl).setMimeType(mimeType) val configs = buildSubtitleConfigs(remoteSubtitles) if (configs.isNotEmpty()) { mediaItemBuilder.setSubtitleConfigurations(configs) @@ -622,7 +624,7 @@ fun ExoPlayerView( val currentPosition = exoPlayer.currentPosition val wasPlaying = exoPlayer.isPlaying - val mediaItemBuilder = MediaItem.Builder().setUri(videoUrl) + val mediaItemBuilder = MediaItem.Builder().setUri(videoUrl).setMimeType(mimeType) val configs = buildSubtitleConfigs(remoteSubtitles) mediaItemBuilder.setSubtitleConfigurations(configs) diff --git a/extensions/index.json b/extensions/index.json index 64f2431..9ed8de0 100644 --- a/extensions/index.json +++ b/extensions/index.json @@ -230,6 +230,42 @@ "priority": 11 } }, + { + "id": "fouranimo", + "name": "FourAnimo", + "description": "Anime only. Sub and dub in HLS, found by AniList id, with captions and intro/outro times.", + "version": "1.0.0", + "versionCode": 1, + "apiVersion": 1, + "authors": ["OpenStream"], + "language": "Multi", + "tags": ["anime", "sub", "dub", "official"], + "status": 3, + "updatedAt": "2026-09-28", + "engine": { + "type": "fouranimo", + "endpoint": "https://cdn.4animo.xyz", + "priority": 12 + } + }, + { + "id": "animegg", + "name": "AnimeGG", + "description": "Anime only. Sub and dub as MP4 files up to 1080p.", + "version": "1.0.0", + "versionCode": 1, + "apiVersion": 1, + "authors": ["OpenStream"], + "language": "Multi", + "tags": ["anime", "sub", "dub", "official"], + "status": 3, + "updatedAt": "2026-09-28", + "engine": { + "type": "animegg", + "endpoint": "https://www.animegg.org", + "priority": 13 + } + }, { "id": "jett", "name": "Jett", From c92fb0159d15178b48627526f37793c3ea73d795 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 17:24:33 +0000 Subject: [PATCH 6/6] Native hoster extractors: Filemoon, StreamWish/VidHide, Voe, Mp4Upload, Vidmoly, ok.ru - HosterExtractors (data/streaming/hosters), written from scratch: reads each host's player config the way its page delivers it. p.a.c.k.e.r scripts via PackerUnpacker (Filemoon inner iframe, StreamWish/VidHide `links.hls4/hls2/hls3`, Mp4Upload `player.src`), Vidmoly inline jwplayer sources, Voe mirror redirect plus its `'hls'` value or obfuscated JSON config (rot13, junk markers, base64, shift, reverse, base64), ok.ru `data-options` metadata (HLS manifest, else the best MP4). Streams carry the hoster as Referer/Origin; HLS is marked. - WebEmbedResolver now tries a native pass first (capped at 6 s): if the embed URL is a known hoster, or its HTML links to one, those streams are returned without starting the hidden WebView. Otherwise the WebView sniffing runs as before. - Anikoto no longer drops non-megaplay servers when the host has an extractor. - CloudStream's GPL extractors were not used or copied. Undocumented third-party behaviour relied on: every hoster's page structure and obfuscation, all of which change without notice; a mismatch returns nothing and falls back. Verified: compile, unit tests; ok.ru end to end against a live video (data-options -> metadata -> hlsManifestUrl -> master playlist 200 with ok.ru Referer). The other five hosts were not tested against live embeds: none of the catalog's aggregators expose hoster links in static HTML, and I didn't go looking for pirated uploads to use as samples. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01H8GM7PfnBT2a3jjqSK47hS --- CLAUDE.md | 5 +- .../streaming/ExtensionProviderRegistry.kt | 5 +- .../streaming/anime/AnimeSiteProviders.kt | 28 +- .../streaming/hosters/HosterExtractors.kt | 257 ++++++++++++++++++ .../streaming/providers/WebEmbedResolver.kt | 66 ++++- 5 files changed, 350 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/ivor/openstream/data/streaming/hosters/HosterExtractors.kt diff --git a/CLAUDE.md b/CLAUDE.md index f4cdbea..457609e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,8 +65,9 @@ Rules: `StreamingRepositoryImpl` resolves them in parallel, ranks with `ServerRanker`, and runs `fallback` providers only when direct ones return nothing (or on "Find more" / failover). Engines: `vidking-direct` (`VidkingDirectApi`, encrypted payload, prefers the master playlist so - quality switches in-player), `web-embed` and `vidking-webview` (`WebEmbedResolver`, hidden - WebView that records media requests). + quality switches in-player), `web-embed` and `vidking-webview` (`WebEmbedResolver`: first a native + pass through `HosterExtractors` (Filemoon, StreamWish/VidHide, Voe, Mp4Upload, Vidmoly, ok.ru) when + the embed is or frames a known hoster, else a hidden WebView that records media requests). Anime engines (`data/streaming/anime`): `anikoto`, `reanime`, `animepahe`, `fouranimo`, `animegg`. `VideoServer` can carry a MIME hint (HLS for URLs without `.m3u8`) and the source's own intro/outro times, which the player prefers over AniSkip. `AnimeEpisodeMapper` diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt b/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt index 9d4b9f5..3101d8d 100644 --- a/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt +++ b/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt @@ -3,6 +3,7 @@ package com.ivor.openstream.data.streaming import com.ivor.openstream.data.streaming.anime.AnikotoProvider import com.ivor.openstream.data.streaming.anime.AnimeEpisodeMapper import com.ivor.openstream.data.streaming.anime.AnimePaheProvider +import com.ivor.openstream.data.streaming.hosters.HosterExtractors import com.ivor.openstream.data.streaming.anime.AnimeGGProvider import com.ivor.openstream.data.streaming.anime.FourAnimoProvider import com.ivor.openstream.data.streaming.anime.AnimeSiteSpec @@ -57,6 +58,7 @@ class ExtensionProviderRegistry @Inject constructor( private val animeEpisodeMapper: AnimeEpisodeMapper, private val megaplayExtractor: MegaplayExtractor, private val cloudflareClearance: CloudflareClearance, + private val hosterExtractors: HosterExtractors, @Named("StreamingClient") private val streamingClient: OkHttpClient, private val json: Json ) { @@ -113,7 +115,8 @@ class ExtensionProviderRegistry @Inject constructor( mapper = animeEpisodeMapper, client = streamingClient, json = json, - megaplay = megaplayExtractor + megaplay = megaplayExtractor, + hosters = hosterExtractors ) ExtensionEngineType.REANIME -> ReAnimeProvider( spec = animeSiteSpec(manifest), diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/anime/AnimeSiteProviders.kt b/app/src/main/java/com/ivor/openstream/data/streaming/anime/AnimeSiteProviders.kt index 4cec687..fbb3b24 100644 --- a/app/src/main/java/com/ivor/openstream/data/streaming/anime/AnimeSiteProviders.kt +++ b/app/src/main/java/com/ivor/openstream/data/streaming/anime/AnimeSiteProviders.kt @@ -1,6 +1,8 @@ package com.ivor.openstream.data.streaming.anime import com.ivor.openstream.data.streaming.StreamProvider +import com.ivor.openstream.data.streaming.hosters.HosterExtractors +import com.ivor.openstream.domain.model.HLS_MIME_TYPE import com.ivor.openstream.domain.model.MediaIdentity import com.ivor.openstream.domain.model.StreamAudio import com.ivor.openstream.domain.model.StreamQuality @@ -72,7 +74,8 @@ class AnikotoProvider( mapper: AnimeEpisodeMapper, private val client: OkHttpClient, private val json: Json, - private val megaplay: MegaplayExtractor + private val megaplay: MegaplayExtractor, + private val hosters: HosterExtractors ) : AnimeSiteProvider(spec, mapper) { private val showPages = ConcurrentHashMap() private val episodeLists = ConcurrentHashMap>>() @@ -101,11 +104,24 @@ class AnikotoProvider( client.fetch("$base/ajax/server?get=$linkId", ajax) ).jsonObject["result"]?.jsonObject val url = result?.get("url")?.jsonPrimitive?.contentOrNull.orEmpty() - // Only megaplay embeds have an extractor; other hosts are skipped. - if ("megaplay" !in url) { - emptyList() - } else { - megaplay.extract(url, "$base/", id, spec.name, label, audio) + when { + "megaplay" in url -> megaplay.extract(url, "$base/", id, spec.name, label, audio) + // Common embed hosts (Filemoon, StreamWish, Voe...) have native extractors. + hosters.supports(url) -> hosters.extract(url, "$base/").map { stream -> + VideoServer( + id = "$id-${stream.url.hashCode()}", + providerId = id, + providerName = spec.name, + name = "$label · ${stream.host} · ${audio.label}", + url = stream.url, + quality = stream.quality, + audio = audio, + headers = stream.headers, + audioLanguage = if (audio == StreamAudio.DUB) "English" else "Japanese", + mimeType = if (stream.isHls) HLS_MIME_TYPE else null + ) + } + else -> emptyList() } }.getOrDefault(emptyList()) } diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/hosters/HosterExtractors.kt b/app/src/main/java/com/ivor/openstream/data/streaming/hosters/HosterExtractors.kt new file mode 100644 index 0000000..6c9aa6f --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/streaming/hosters/HosterExtractors.kt @@ -0,0 +1,257 @@ +package com.ivor.openstream.data.streaming.hosters + +import android.util.Base64 +import android.util.Log +import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT +import com.ivor.openstream.data.streaming.anime.PackerUnpacker +import com.ivor.openstream.domain.model.StreamQuality +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.util.Locale +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** A direct stream pulled out of a hoster's embed page. */ +data class HosterStream( + val url: String, + /** Headers the stream's CDN wants (usually the hoster as Referer). */ + val headers: Map, + val isHls: Boolean, + val quality: StreamQuality, + /** Short hoster name for server labels, e.g. "Filemoon". */ + val host: String +) + +/** + * Turns common embed hosts into direct streams, so their embeds play without the hidden WebView: + * Filemoon, StreamWish/VidHide (same player family), Voe, Mp4Upload, Vidmoly and ok.ru. + * + * Written from how each page delivers its player config (p.a.c.k.e.r scripts, inline player + * setup, redirects, JSON options). All of it is undocumented and changes without notice; an + * extractor that stops matching just returns nothing and the caller falls back. + */ +@Singleton +class HosterExtractors @Inject constructor( + @Named("StreamingClient") private val client: OkHttpClient, + private val json: Json +) { + private enum class Host(val label: String, val domains: List) { + FILEMOON("Filemoon", listOf("filemoon", "moonplayer", "kerapoxy", "bf0skv", "fmoonembed", "moflix", "cinegrab")), + STREAMWISH( + "StreamWish", + listOf("streamwish", "wishembed", "swdyu", "wishfast", "sfastwish", "strwish", "playerwish", "hlswish", "awish", "dwish", "embedwish", "mwish", "flaswish", "obeywish", "cdnwish", "asnwish", "jodwish", "streamhg", "dhcplay", "hglink") + ), + VIDHIDE("VidHide", listOf("vidhide", "filelions", "alions", "dlions", "mlions", "vidhidepro", "vidhidevip", "vidhidefast", "ryderjet", "smoothpre", "movearnpre", "dinisglows", "mivalyo", "dintezuvio")), + VOE("Voe", listOf("voe.sx", "voe-", "voeun", "voeunblock", "tubelessceliolymph", "maxfinishseveral", "crystaltreatmenteast", "graceaddresscommunity", "christopheruntilpoint", "launchreliantcleaverriver", "jennifercertaindevelopment")), + MP4UPLOAD("Mp4Upload", listOf("mp4upload")), + VIDMOLY("Vidmoly", listOf("vidmoly")), + OKRU("ok.ru", listOf("ok.ru", "odnoklassniki")); + + fun matches(host: String): Boolean = domains.any { host.contains(it) } + } + + /** True when [url] is on a host this class can extract. */ + fun supports(url: String): Boolean = hostOf(url) != null + + /** Direct streams from a hoster embed; empty when the page didn't match (or isn't a hoster). */ + suspend fun extract(url: String, referer: String? = null): List = withContext(Dispatchers.IO) { + val host = hostOf(url) ?: return@withContext emptyList() + runCatching { + when (host) { + Host.FILEMOON -> filemoon(url, referer) + Host.STREAMWISH, Host.VIDHIDE -> packedPlayer(url, referer, host) + Host.VOE -> voe(url, referer) + Host.MP4UPLOAD -> mp4upload(url, referer) + Host.VIDMOLY -> vidmoly(url, referer) + Host.OKRU -> okru(url) + } + }.onFailure { Log.w(TAG, "${host.label} extraction failed: ${it.message}") } + .getOrDefault(emptyList()) + } + + private fun hostOf(url: String): Host? { + val host = url.toHttpUrlOrNull()?.host?.lowercase(Locale.ROOT) ?: return null + return Host.entries.firstOrNull { it.matches(host) } + } + + // region Hosts + + /** Filemoon: the page wraps the player in an iframe; the player's packed script holds `file:`. */ + private fun filemoon(url: String, referer: String?): List { + var pageUrl = url + var html = fetch(url, referer) + Regex("""]+src="(https?://[^"]+)"""").find(html)?.groupValues?.get(1)?.let { inner -> + html = fetch(inner, url) + pageUrl = inner + } + val script = PackerUnpacker.unpack(packedScript(html) ?: html) + val file = Regex("""file\s*:\s*"([^"]+)"""").find(script)?.groupValues?.get(1) ?: return emptyList() + return listOf(stream(absolute(file, pageUrl), pageUrl, Host.FILEMOON)) + } + + /** + * StreamWish and VidHide share a player: a packed jwplayer setup whose `links` object lists + * `hls4`/`hls2`/`hls3` URLs (hls4 and relative ones are served by the page's own host). + */ + private fun packedPlayer(url: String, referer: String?, host: Host): List { + val embed = url.replace("/f/", "/e/").replace("/d/", "/e/") + val html = fetch(embed, referer) + val script = PackerUnpacker.unpack(packedScript(html) ?: html) + val links = Regex(""""(hls\d)"\s*:\s*"([^"]+)"""").findAll(script) + .associate { it.groupValues[1] to it.groupValues[2] } + val file = listOf("hls4", "hls2", "hls3").firstNotNullOfOrNull { links[it] } + ?: Regex("""file\s*:\s*"([^"]+\.m3u8[^"]*)"""").find(script)?.groupValues?.get(1) + ?: return emptyList() + return listOf(stream(absolute(file, embed), embed, host)) + } + + /** + * Voe: mirror domains redirect first. Older pages carry `'hls': ''`; newer ones + * hide a JSON config in `""", RegexOption.DOT_MATCHES_ALL) + .find(html)?.groupValues?.get(1)?.let(::voeDecode) + ?: return emptyList() + return listOf(stream(source, pageUrl, Host.VOE)) + } + + private fun voeDecode(encoded: String): String? = runCatching { + val rot13 = encoded.map { char -> + when (char) { + in 'a'..'z' -> 'a' + (char - 'a' + 13) % 26 + in 'A'..'Z' -> 'A' + (char - 'A' + 13) % 26 + else -> char + } + }.joinToString("") + val cleaned = VOE_JUNK.fold(rot13) { text, junk -> text.replace(junk, "") } + val step1 = String(Base64.decode(cleaned, Base64.DEFAULT)) + val shifted = step1.map { (it.code - 3).toChar() }.joinToString("").reversed() + val config = json.parseToJsonElement(String(Base64.decode(shifted, Base64.DEFAULT))).jsonObject + (config["source"] ?: config["direct_access_url"])?.jsonPrimitive?.contentOrNull + }.getOrNull() + + /** Mp4Upload: `player.src({ type: "video/mp4", src: "..." })`, sometimes packed. */ + private fun mp4upload(url: String, referer: String?): List { + val id = Regex("""(?:embed-)?([a-z0-9]{12})""").findAll(url.substringAfterLast('/')).lastOrNull()?.groupValues?.get(1) + val embed = if (id != null) "https://www.mp4upload.com/embed-$id.html" else url + val html = fetch(embed, referer ?: "https://www.mp4upload.com/") + val script = PackerUnpacker.unpack(packedScript(html) ?: html) + val src = Regex("""src\s*:\s*"(https?://[^"]+\.mp4[^"]*)"""").find(script)?.groupValues?.get(1) + ?: return emptyList() + return listOf(stream(src, "https://www.mp4upload.com/", Host.MP4UPLOAD)) + } + + /** Vidmoly: a jwplayer `sources: [{file:"...m3u8"}]` right in the page. */ + private fun vidmoly(url: String, referer: String?): List { + val embed = url.replace("/w/", "/embed-").let { if ("/embed-" in it && !it.endsWith(".html")) "$it.html" else it } + val html = fetch(embed, referer ?: "https://vidmoly.to/") + val script = PackerUnpacker.unpack(packedScript(html) ?: html) + val file = Regex("""file\s*:\s*"([^"]+\.m3u8[^"]*)"""").find(script)?.groupValues?.get(1) ?: return emptyList() + return listOf(stream(file, embed, Host.VIDMOLY)) + } + + /** ok.ru: the embed's `data-options` JSON has metadata with an HLS manifest and MP4 renditions. */ + private fun okru(url: String): List { + val id = Regex("""/video(?:embed)?/(\d+)""").find(url)?.groupValues?.get(1) ?: return emptyList() + val html = fetch("https://ok.ru/videoembed/$id", "https://ok.ru/") + val options = Regex("""data-options="([^"]+)"""").find(html)?.groupValues?.get(1) + ?.let { json.parseToJsonElement(unescapeHtml(it)).jsonObject } ?: return emptyList() + val metadata: JsonObject = when (val raw = options["flashvars"]?.jsonObject?.get("metadata")) { + is JsonObject -> raw + is JsonPrimitive -> raw.contentOrNull?.let { json.parseToJsonElement(it).jsonObject } + else -> null + } ?: return emptyList() + val headers = mapOf("Referer" to "https://ok.ru/", "User-Agent" to BROWSER_USER_AGENT) + metadata["hlsManifestUrl"]?.jsonPrimitive?.contentOrNull?.takeIf { it.startsWith("http") }?.let { hls -> + return listOf(HosterStream(hls, headers, isHls = true, quality = StreamQuality.parse("1080p"), host = Host.OKRU.label)) + } + val best = (metadata["videos"] as? JsonArray).orEmpty() + .mapNotNull { element -> (element as? JsonObject)?.let { it["name"].text() to it["url"].text() } } + .filter { (_, videoUrl) -> videoUrl?.startsWith("http") == true } + .maxByOrNull { (name, _) -> OKRU_QUALITIES.indexOf(name) } + ?: return emptyList() + return listOf( + HosterStream(best.second!!, headers, isHls = false, quality = StreamQuality.parse(okruQuality(best.first)), host = Host.OKRU.label) + ) + } + + // endregion + + private fun stream(url: String, pageUrl: String, host: Host): HosterStream { + val origin = pageUrl.toHttpUrlOrNull()?.let { "${it.scheme}://${it.host}" } + return HosterStream( + url = url, + headers = buildMap { + put("Referer", "${origin ?: pageUrl}/") + origin?.let { put("Origin", it) } + put("User-Agent", BROWSER_USER_AGENT) + }, + isHls = ".m3u8" in url || "/hls" in url, + quality = StreamQuality.parse(Regex("""(\d{3,4})p""").find(url)?.value ?: "720p"), + host = host.label + ) + } + + private fun fetch(url: String, referer: String?): String { + val request = Request.Builder() + .url(url) + .header("User-Agent", BROWSER_USER_AGENT) + .apply { referer?.let { header("Referer", it) } } + .build() + return client.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw IOException("${response.request.url.host} returned HTTP ${response.code}") + response.body?.string().orEmpty() + } + } + + private fun packedScript(html: String): String? = + Regex("""eval\(function\(p,a,c,k,e,[dr]\).*?\.split\('\|'\)[^<]*""", RegexOption.DOT_MATCHES_ALL).find(html)?.value + + private fun absolute(file: String, pageUrl: String): String = + if (file.startsWith("http")) file else pageUrl.toHttpUrlOrNull()?.resolve(file)?.toString() ?: file + + private fun JsonElement?.text(): String? = (this as? JsonPrimitive)?.contentOrNull + + private fun unescapeHtml(value: String) = value + .replace(""", "\"").replace(""", "\"").replace("'", "'").replace("'", "'") + .replace("<", "<").replace(">", ">").replace("&", "&") + + private fun okruQuality(name: String?) = when (name) { + "full" -> "1080p" + "hd" -> "720p" + "sd" -> "480p" + "low" -> "360p" + else -> "240p" + } + + private companion object { + const val TAG = "HosterExtractors" + val OKRU_QUALITIES = listOf("mobile", "lowest", "low", "sd", "hd", "full") + val VOE_JUNK = listOf("@$", "^^", "~@", "%?", "*~", "!!", "#&") + } +} diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/providers/WebEmbedResolver.kt b/app/src/main/java/com/ivor/openstream/data/streaming/providers/WebEmbedResolver.kt index b369f76..a67cc1d 100644 --- a/app/src/main/java/com/ivor/openstream/data/streaming/providers/WebEmbedResolver.kt +++ b/app/src/main/java/com/ivor/openstream/data/streaming/providers/WebEmbedResolver.kt @@ -31,6 +31,15 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import javax.inject.Inject +import javax.inject.Named +import com.ivor.openstream.data.streaming.hosters.HosterExtractors +import com.ivor.openstream.domain.model.HLS_MIME_TYPE +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import okhttp3.Request import javax.inject.Singleton import kotlin.coroutines.resume @@ -98,11 +107,59 @@ class WebEmbedProvider( @Singleton class WebEmbedResolver @Inject constructor( @ApplicationContext private val context: Context, - private val json: Json + private val json: Json, + private val hosters: HosterExtractors, + @Named("StreamingClient") private val client: OkHttpClient ) { - @SuppressLint("SetJavaScriptEnabled") + /** + * Native streams first: when the embed is (or directly frames) a hoster [HosterExtractors] + * knows, its stream is pulled out with plain requests. Only when that finds nothing does the + * hidden WebView load the page. + */ suspend fun resolve(spec: WebEmbedSpec, identity: MediaIdentity): List { val embedUrl = spec.embedUrl(identity) ?: return emptyList() + val native = runCatching { withTimeoutOrNull(NATIVE_TIMEOUT_MS) { nativeStreams(spec, embedUrl) } } + .getOrNull().orEmpty() + if (native.isNotEmpty()) return native + return sniff(spec, embedUrl) + } + + private suspend fun nativeStreams(spec: WebEmbedSpec, embedUrl: String): List { + val candidates = if (hosters.supports(embedUrl)) { + listOf(embedUrl) + } else { + val html = withContext(Dispatchers.IO) { + client.newCall( + Request.Builder().url(embedUrl) + .header("User-Agent", BROWSER_USER_AGENT) + .header("Referer", originHeaders(embedUrl).getValue("Referer")) + .build() + ).execute().use { if (it.isSuccessful) it.body?.string() else null } + } ?: return emptyList() + HOSTER_LINK.findAll(html).map { it.groupValues[1] } + .filter { hosters.supports(it) } + .distinct() + .take(MAX_NATIVE_CANDIDATES) + .toList() + } + return coroutineScope { + candidates.map { url -> async { hosters.extract(url, embedUrl) } }.awaitAll().flatten() + }.distinctBy { it.url }.map { stream -> + VideoServer( + id = "${spec.id}-${stream.url.hashCode()}", + providerId = spec.id, + providerName = spec.providerName, + name = "${spec.name} · ${stream.host}", + url = stream.url, + quality = stream.quality, + headers = stream.headers, + mimeType = if (stream.isHls) HLS_MIME_TYPE else null + ) + } + } + + @SuppressLint("SetJavaScriptEnabled") + private suspend fun sniff(spec: WebEmbedSpec, embedUrl: String): List { val embedHost = Uri.parse(embedUrl).host.orEmpty() val defaultHeaders = originHeaders(embedUrl) @@ -342,6 +399,11 @@ class WebEmbedResolver @Inject constructor( const val BRIDGE_NAME = "OpenStreamSniffer" const val TIMEOUT_MS = 17_000L const val SETTLE_DELAY_MS = 1_500L + const val MAX_NATIVE_CANDIDATES = 4 + /** The native pass must not hold up the WebView fallback for long. */ + const val NATIVE_TIMEOUT_MS = 6_000L + /** iframe/data-src/link targets and quoted URLs in an embed page. */ + val HOSTER_LINK = Regex("""["'](https?://[^"'\s<>]+)["']""") val PASSTHROUGH_HEADERS = listOf("Referer", "Origin", "User-Agent") val BLOCKED_HOST_MARKERS = listOf("googleads", "doubleclick", "telemetry", "/ads/", "vast")