diff --git a/CLAUDE.md b/CLAUDE.md index 517282c..457609e 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: 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 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 8). `fallbackToDestructiveMigration` is only a safety net; users' downloads and progress live there. ## How the main features work @@ -64,9 +65,12 @@ 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). - Anime engines (`data/streaming/anime`): `anikoto`, `reanime`, `animepahe`. `AnimeEpisodeMapper` + 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` 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. @@ -77,17 +81,37 @@ 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. - 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. - **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. +- **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 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 +128,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..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 @@ -1,14 +1,19 @@ 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 +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 @@ -19,20 +24,32 @@ 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, + val profiles: 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: 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( private val watchLaterDao: WatchLaterDao, private val watchProgressDao: WatchProgressDao, private val hiddenTitleDao: HiddenTitleDao, + private val customListDao: CustomListDao, + private val profiles: ProfileRepository, private val settingsStore: AppSettingsStore ) { private val json = Json { @@ -44,14 +61,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() }, + 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) } @@ -59,29 +96,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.settings?.let { saved -> settingsStore.update { saved.applyTo(it) } } + 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)) } + } + 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 + 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" } } @@ -91,12 +157,54 @@ 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(), + val lists: List = emptyList(), 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, + 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, @@ -106,7 +214,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) @@ -128,9 +236,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 ) } @@ -141,7 +249,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) @@ -156,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, @@ -166,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 ) } @@ -178,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/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..30ad75b --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/cast/CastMediaProxy.kt @@ -0,0 +1,585 @@ +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.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 +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 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 subtitleFetcher: SubtitleFetcher +) { + 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 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( + 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..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 @@ -2,14 +2,19 @@ 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.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 @@ -19,9 +24,12 @@ import com.ivor.openstream.data.local.entity.WatchProgressEntity DownloadEntity::class, IdMappingEntity::class, WatchProgressEntity::class, - HiddenTitleEntity::class + HiddenTitleEntity::class, + CustomListEntity::class, + CustomListItemEntity::class, + ProfileEntity::class ], - version = 6, + version = 8, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { @@ -30,4 +38,6 @@ abstract class AppDatabase : RoomDatabase() { abstract fun idMappingDao(): IdMappingDao 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 new file mode 100644 index 0000000..92f98a6 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/local/dao/CustomListDao.kt @@ -0,0 +1,96 @@ +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 + WHERE l.profileId = :profileId + ORDER BY l.updatedAt DESC + """ + ) + fun observeSummaries(profileId: Long): Flow> + + @Query("SELECT * FROM custom_lists WHERE id = :listId") + fun observeList(listId: Long): Flow + + @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 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 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 + + @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 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 new file mode 100644 index 0000000..bf6228e --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/local/entity/CustomListEntity.kt @@ -0,0 +1,31 @@ +package com.ivor.openstream.data.local.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** 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 profileId: Long = ProfileEntity.DEFAULT_ID +) + +/** 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/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/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/CustomListRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/CustomListRepository.kt new file mode 100644 index 0000000..c5ca67a --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/repository/CustomListRepository.kt @@ -0,0 +1,47 @@ +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 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 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, + private val settings: AppSettingsStore +) { + private val profileId: Long get() = settings.activeProfileId.value + + fun summaries(): Flow> = settings.activeProfileId.flatMapLatest { dao.observeSummaries(it) } + + 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> = + 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(profileId, trimmed) ?: dao.insertList(CustomListEntity(name = trimmed, profileId = profileId)) + } + + 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/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/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/SkipTimesRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/SkipTimesRepository.kt index 5c3b0d6..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 @@ -1,6 +1,11 @@ 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 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 @@ -17,37 +22,51 @@ 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. */ -data class SkipSegment(val type: SkipType, val startMs: Long, val endMs: Long) - /** * 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. + * + * 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. * - * 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 + * 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 +82,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 +105,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 +125,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 +161,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/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/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..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) @@ -57,6 +68,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 @@ -69,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() } @@ -85,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) ) } @@ -101,5 +129,10 @@ 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" + 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/data/streaming/ExtensionProviderRegistry.kt b/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt index 74692e1..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,9 @@ 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 import com.ivor.openstream.data.streaming.anime.CloudflareClearance import com.ivor.openstream.data.streaming.anime.MegaplayExtractor @@ -55,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 ) { @@ -111,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), @@ -125,6 +130,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/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/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/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/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") 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/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..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,9 +2,14 @@ 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 +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 +102,125 @@ 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`)" + ) + } + } + + /** + * 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 { @@ -105,7 +229,10 @@ object DatabaseModule { AppDatabase::class.java, "open_stream_db" ) - .addMigrations(migration2To3, migration3To4, migration4To5, migration5To6) + .addMigrations(migration2To3, migration3To4, migration4To5, migration5To6, migration6To7, migration7To8) + .addCallback(object : RoomDatabase.Callback() { + override fun onCreate(db: SupportSQLiteDatabase) = seedDefaultProfile(db) + }) .fallbackToDestructiveMigration() .build() } @@ -130,6 +257,16 @@ object DatabaseModule { return database.hiddenTitleDao() } + @Provides + fun provideProfileDao(database: AppDatabase): ProfileDao { + return database.profileDao() + } + + @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/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/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/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/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/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/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/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/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..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 @@ -36,6 +36,12 @@ 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.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 @@ -106,6 +112,12 @@ 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" + } + data object Details : Screen("details/{mediaType}/{animeId}") { fun createRoute(mediaType: String, animeId: Int) = "details/$mediaType/$animeId" } @@ -144,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) { @@ -251,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 } ) } @@ -272,6 +291,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)) } ) } @@ -316,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() } ) } @@ -533,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/player/PictureInPicture.kt b/app/src/main/java/com/ivor/openstream/presentation/player/PictureInPicture.kt index ec5d736..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? { @@ -51,26 +55,67 @@ 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. - * 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) @@ -94,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 { @@ -125,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 fe716f4..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 @@ -102,6 +102,11 @@ 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.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 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 +159,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 +192,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,9 +219,30 @@ fun PlayerScreen( var togglePlaybackSignal by remember { mutableIntStateOf(0) } PictureInPictureEffect( - enabled = videoUrl != null, + 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 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)) + } + } + } ) // Trigger data fetch @@ -292,6 +329,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 +408,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, @@ -410,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, @@ -433,7 +501,11 @@ fun PlayerScreen( showServerPicker = viewModel.activeServer.value == null } }, - onPlaybackReady = viewModel::onPlaybackReady + onPlaybackReady = viewModel::onPlaybackReady, + onCastClick = { showCastSheet = true }.takeIf { castStatus.supported }, + onPictureInPictureClick = enterPictureInPicture, + loadSubtitleText = viewModel::loadSubtitleText, + mimeType = activeServer?.mimeType.takeIf { downloadId == null } ) } } else { @@ -582,7 +654,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 +694,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..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,10 +44,18 @@ 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 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" @@ -97,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() @@ -117,6 +131,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 +371,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 +415,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 +491,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..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,6 +113,11 @@ 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.domain.model.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 @OptIn(UnstableApi::class) @kotlin.OptIn(ExperimentalMaterial3ExpressiveApi::class) @@ -170,7 +175,15 @@ 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, + /** 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") }, + /** 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) { @@ -329,47 +342,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() @@ -377,8 +360,6 @@ fun ExoPlayerView( } } - - // Helper: parse available tracks from ExoPlayer fun parseTracksFromPlayer(tracks: Tracks) { val qualities = mutableListOf() @@ -624,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) @@ -643,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) @@ -1202,6 +1183,13 @@ fun ExoPlayerView( changeVideoScale(videoScale.next()) areControlsVisible = true }, + onCastClick = onCastClick, + onPictureInPictureClick = onPictureInPictureClick?.let { enter -> + { + areControlsVisible = false + enter() + } + }, onBackClick = onBackClick ) @@ -1326,7 +1314,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 +1470,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, @@ -1591,7 +1512,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 @@ -1665,44 +1587,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('_', '-') @@ -1712,13 +1596,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 - -/** 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/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..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 @@ -25,6 +25,9 @@ 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.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 @@ -90,6 +93,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() @@ -257,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( @@ -299,6 +329,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 +348,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 +375,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/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..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 @@ -108,6 +109,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(", ") @@ -164,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/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/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 3d593f4..32472e1 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,13 @@ 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. 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 @@ -74,10 +77,12 @@ 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. +- [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 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", 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" }