Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-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 { *; }
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
</intent-filter>
</service>

<!-- Cast: receiver app, media notification and lock-screen controls while casting. -->
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.ivor.openstream.data.cast.CastOptionsProvider" />

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
Expand Down
36 changes: 36 additions & 0 deletions app/src/main/assets/extensions/official-repo.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/com/ivor/openstream/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import com.ivor.openstream.data.settings.AppSettingsStore
import com.ivor.openstream.data.settings.ThemeMode
import com.ivor.openstream.presentation.player.session.PlaybackSession
import com.ivor.openstream.presentation.shortcuts.AppShortcut
import com.ivor.openstream.presentation.shortcuts.ShortcutRequest
import com.ivor.openstream.ui.theme.OpenStreamTheme
Expand All @@ -35,6 +36,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var appSettings: AppSettingsStore

@Inject
lateinit var playbackSession: PlaybackSession

private var shortcutRequest by mutableStateOf<ShortcutRequest?>(null)
private var deepLinkRequest by mutableStateOf<DeepLinkRequest?>(null)

Expand All @@ -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)
Expand Down Expand Up @@ -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
}
}
Loading
Loading